From 1140080927d764048d2adb4e1da860df3edd6b2d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 11:28:48 +0900 Subject: [PATCH 01/86] fix(agents): deny apply_patch for GPT models to prevent verification hangs (#2935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPT models (5.3-codex, 5.4, etc.) frequently hang when using apply_patch due to verification loops. This adds: 1. Tool restriction: apply_patch is denied for GPT variants of Hephaestus, Sisyphus-Junior, and Sisyphus agents 2. Prompt guidance: GPT-specific prompts now explicitly instruct using edit/write tools instead of apply_patch 3. Removed the 'Always use apply_patch' instruction from sisyphus-junior/gpt-5-4.ts that contradicted the fix The deny is model-conditional — Claude variants retain apply_patch access since it works reliably there. --- src/agents/delegation-trust-prompt.test.ts | 2 ++ src/agents/hephaestus/agent.test.ts | 33 +++++++++++++++++++++ src/agents/hephaestus/agent.ts | 3 +- src/agents/hephaestus/gpt-5-3-codex.ts | 1 + src/agents/hephaestus/gpt-5-4.ts | 2 +- src/agents/hephaestus/gpt.ts | 1 + src/agents/sisyphus-junior/agent.ts | 6 ++-- src/agents/sisyphus-junior/gpt-5-3-codex.ts | 1 + src/agents/sisyphus-junior/gpt-5-4.ts | 2 +- src/agents/sisyphus-junior/gpt.ts | 1 + src/agents/sisyphus-junior/index.test.ts | 30 +++++++++++++++++++ src/agents/sisyphus.ts | 2 ++ src/agents/sisyphus/gpt-5-4.ts | 2 +- src/agents/tool-restrictions.test.ts | 20 +++++++++++++ 14 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/agents/delegation-trust-prompt.test.ts b/src/agents/delegation-trust-prompt.test.ts index 03c4ea49a..2de84132a 100644 --- a/src/agents/delegation-trust-prompt.test.ts +++ b/src/agents/delegation-trust-prompt.test.ts @@ -114,6 +114,8 @@ describe("delegation trust prompt rules", () => { 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") + expect(prompt).toContain("Do not use `apply_patch`") + expect(prompt).toContain("`edit` and `write`") }) test("Sisyphus-Junior GPT-5.4 prompt forbids duplicate delegated exploration", () => { diff --git a/src/agents/hephaestus/agent.test.ts b/src/agents/hephaestus/agent.test.ts index 82accf8ee..0885e7839 100644 --- a/src/agents/hephaestus/agent.test.ts +++ b/src/agents/hephaestus/agent.test.ts @@ -192,6 +192,8 @@ describe("createHephaestusAgent", () => { expect(config.prompt).toContain("You build context by examining"); expect(config.prompt).toContain("Never chain together bash commands"); expect(config.prompt).toContain(""); + expect(config.prompt).toContain("Do not use `apply_patch`"); + expect(config.prompt).toContain("`edit` and `write`"); }); test("GPT 5.3-codex model includes GPT-5.3 specific prompt content", () => { @@ -205,6 +207,8 @@ describe("createHephaestusAgent", () => { expect(config.prompt).toContain("Senior Staff Engineer"); expect(config.prompt).toContain("Hard Constraints"); expect(config.prompt).toContain(""); + expect(config.prompt).toContain("Do not use `apply_patch`"); + expect(config.prompt).toContain("`edit` and `write`"); }); test("includes Hephaestus identity in prompt", () => { @@ -219,6 +223,35 @@ describe("createHephaestusAgent", () => { expect(config.prompt).toContain("autonomous deep worker"); }); + test("generic GPT model includes apply_patch workaround guidance", () => { + // given + const model = "openai/gpt-4o"; + + // when + const config = createHephaestusAgent(model); + + // then + expect(config.prompt).toContain("Do not use `apply_patch`"); + expect(config.prompt).toContain("`edit` and `write`"); + }); + + test("GPT models deny apply_patch while non-GPT models do not", () => { + // given + const gpt54Model = "openai/gpt-5.4"; + const gptGenericModel = "openai/gpt-4o"; + const claudeModel = "anthropic/claude-opus-4-6"; + + // when + const gpt54Config = createHephaestusAgent(gpt54Model); + const gptGenericConfig = createHephaestusAgent(gptGenericModel); + const claudeConfig = createHephaestusAgent(claudeModel); + + // then + expect(gpt54Config.permission ?? {}).toHaveProperty("apply_patch", "deny"); + expect(gptGenericConfig.permission ?? {}).toHaveProperty("apply_patch", "deny"); + expect(claudeConfig.permission ?? {}).not.toHaveProperty("apply_patch"); + }); + test("useTaskSystem=true produces Task Discipline prompt", () => { // given const model = "openai/gpt-5.4"; diff --git a/src/agents/hephaestus/agent.ts b/src/agents/hephaestus/agent.ts index 5d27e6220..fc21027e5 100644 --- a/src/agents/hephaestus/agent.ts +++ b/src/agents/hephaestus/agent.ts @@ -1,6 +1,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"; import type { AgentMode, AgentPromptMetadata } from "../types"; -import { isGpt5_4Model, isGpt5_3CodexModel } from "../types"; +import { isGptModel, isGpt5_4Model, isGpt5_3CodexModel } from "../types"; import type { AvailableAgent, AvailableTool, @@ -120,6 +120,7 @@ export function createHephaestusAgent( permission: { question: "allow", call_omo_agent: "deny", + ...(isGptModel(model) ? { apply_patch: "deny" as const } : {}), } as AgentConfig["permission"], reasoningEffort: "medium", }; diff --git a/src/agents/hephaestus/gpt-5-3-codex.ts b/src/agents/hephaestus/gpt-5-3-codex.ts index 732a83afe..93a7ef32a 100644 --- a/src/agents/hephaestus/gpt-5-3-codex.ts +++ b/src/agents/hephaestus/gpt-5-3-codex.ts @@ -448,6 +448,7 @@ ${oracleSection} 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/hephaestus/gpt-5-4.ts b/src/agents/hephaestus/gpt-5-4.ts index 9447d183f..2c0f8410b 100644 --- a/src/agents/hephaestus/gpt-5-4.ts +++ b/src/agents/hephaestus/gpt-5-4.ts @@ -252,7 +252,7 @@ ${antiPatterns} 1. **Explore**: Fire 2-5 explore/librarian agents in parallel + direct tool reads. Goal: complete understanding, not just enough context. 2. **Plan**: List files to modify, specific changes, dependencies, complexity estimate. 3. **Decide**: Trivial (<10 lines, single file) -> self. Complex (multi-file, >100 lines) -> delegate. -4. **Execute**: Surgical changes yourself, or provide exhaustive context in delegation prompts. Match existing patterns. Minimal diff. Search the codebase for similar patterns before writing code. Default to ASCII. Add comments only for non-obvious blocks. +4. **Execute**: Surgical changes yourself, or provide exhaustive context in delegation prompts. Match existing patterns. Minimal diff. Search the codebase for similar patterns before writing code. Default to ASCII. Add comments only for non-obvious blocks. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. 5. **Verify**: \`lsp_diagnostics\` on all modified files (zero errors) -> run related tests (\`foo.ts\` -> \`foo.test.ts\`) -> typecheck -> build if applicable (exit 0). Fix only issues your changes caused. If verification fails, return to step 1 with a materially different approach. After three attempts: stop, revert to last working state, document what you tried, consult Oracle. If Oracle cannot resolve, ask the user. diff --git a/src/agents/hephaestus/gpt.ts b/src/agents/hephaestus/gpt.ts index bfa7ae4b4..b305d1128 100644 --- a/src/agents/hephaestus/gpt.ts +++ b/src/agents/hephaestus/gpt.ts @@ -311,6 +311,7 @@ ${oracleSection} 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/agent.ts b/src/agents/sisyphus-junior/agent.ts index c8178f7fb..febb2512b 100644 --- a/src/agents/sisyphus-junior/agent.ts +++ b/src/agents/sisyphus-junior/agent.ts @@ -30,6 +30,7 @@ const MODE: AgentMode = "subagent" // Core tools that Sisyphus-Junior must NEVER have access to // Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian const BLOCKED_TOOLS = ["task"] +const GPT_BLOCKED_TOOLS = ["task", "apply_patch"] export const SISYPHUS_JUNIOR_DEFAULTS = { model: "anthropic/claude-sonnet-4-6", @@ -91,13 +92,14 @@ export function createSisyphusJuniorAgentWithOverrides( const promptAppend = override?.prompt_append const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend) + const blockedTools = isGptModel(model) ? GPT_BLOCKED_TOOLS : BLOCKED_TOOLS - const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) + const baseRestrictions = createAgentToolRestrictions(blockedTools) const userPermission = (override?.permission ?? {}) as Record const basePermission = baseRestrictions.permission const merged: Record = { ...userPermission } - for (const tool of BLOCKED_TOOLS) { + for (const tool of blockedTools) { merged[tool] = "deny" } merged.call_omo_agent = "allow" diff --git a/src/agents/sisyphus-junior/gpt-5-3-codex.ts b/src/agents/sisyphus-junior/gpt-5-3-codex.ts index 8394afc7c..02e8d07fa 100644 --- a/src/agents/sisyphus-junior/gpt-5-3-codex.ts +++ b/src/agents/sisyphus-junior/gpt-5-3-codex.ts @@ -92,6 +92,7 @@ Style: 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/gpt-5-4.ts b/src/agents/sisyphus-junior/gpt-5-4.ts index fabd679e8..81e706530 100644 --- a/src/agents/sisyphus-junior/gpt-5-4.ts +++ b/src/agents/sisyphus-junior/gpt-5-4.ts @@ -96,7 +96,7 @@ Style: 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks -4. Always use apply_patch for manual code edits. Do not use cat or echo for file creation/editing. Formatting commands or bulk edits don't need apply_patch +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. 5. Do not chain bash commands with separators - each command should be a separate tool call ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/gpt.ts b/src/agents/sisyphus-junior/gpt.ts index 83339fc11..c69ab7a2a 100644 --- a/src/agents/sisyphus-junior/gpt.ts +++ b/src/agents/sisyphus-junior/gpt.ts @@ -93,6 +93,7 @@ Style: 1. SEARCH existing codebase for similar patterns/styles 2. Match naming, indentation, import styles, error handling conventions 3. Default to ASCII. Add comments only for non-obvious blocks +4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. ### After Implementation (MANDATORY - DO NOT SKIP) diff --git a/src/agents/sisyphus-junior/index.test.ts b/src/agents/sisyphus-junior/index.test.ts index dace8bf38..00a4c0377 100644 --- a/src/agents/sisyphus-junior/index.test.ts +++ b/src/agents/sisyphus-junior/index.test.ts @@ -350,6 +350,8 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { expect(result.prompt).toContain("Scope Discipline") expect(result.prompt).toContain("") expect(result.prompt).toContain("Progress Updates") + expect(result.prompt).toContain("Do not use `apply_patch`") + expect(result.prompt).toContain("`edit` and `write`") }) test("GPT 5.4 model uses GPT-5.4 specific prompt", () => { @@ -362,6 +364,9 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { // then expect(result.prompt).toContain("expert coding agent") expect(result.prompt).toContain("") + expect(result.prompt).toContain("Do not use `apply_patch`") + expect(result.prompt).toContain("`edit` and `write`") + expect(result.prompt).not.toContain("Always use apply_patch") }) test("GPT 5.3 Codex model uses GPT-5.3-codex specific prompt", () => { @@ -374,6 +379,28 @@ describe("createSisyphusJuniorAgentWithOverrides", () => { // then expect(result.prompt).toContain("Senior Engineer") expect(result.prompt).toContain("") + expect(result.prompt).toContain("Do not use `apply_patch`") + expect(result.prompt).toContain("`edit` and `write`") + }) + + test("GPT variants deny apply_patch while Claude variants do not", () => { + // given + const gpt54Override = { model: "openai/gpt-5.4" } + const gpt53Override = { model: "openai/gpt-5.3-codex" } + const gptGenericOverride = { model: "openai/gpt-4o" } + const claudeOverride = { model: "anthropic/claude-sonnet-4-6" } + + // when + const gpt54Result = createSisyphusJuniorAgentWithOverrides(gpt54Override) + const gpt53Result = createSisyphusJuniorAgentWithOverrides(gpt53Override) + const gptGenericResult = createSisyphusJuniorAgentWithOverrides(gptGenericOverride) + const claudeResult = createSisyphusJuniorAgentWithOverrides(claudeOverride) + + // then + expect(gpt54Result.permission ?? {}).toHaveProperty("apply_patch", "deny") + expect(gpt53Result.permission ?? {}).toHaveProperty("apply_patch", "deny") + expect(gptGenericResult.permission ?? {}).toHaveProperty("apply_patch", "deny") + expect(claudeResult.permission ?? {}).not.toHaveProperty("apply_patch") }) test("prompt_append is added after base prompt", () => { @@ -494,6 +521,7 @@ describe("buildSisyphusJuniorPrompt", () => { expect(prompt).toContain("expert coding agent") expect(prompt).toContain("Scope Discipline") expect(prompt).toContain("") + expect(prompt).toContain("Do not use `apply_patch`") }) test("GPT 5.3 Codex model uses GPT-5.3-codex prompt", () => { @@ -507,6 +535,7 @@ describe("buildSisyphusJuniorPrompt", () => { expect(prompt).toContain("Senior Engineer") expect(prompt).toContain("Scope Discipline") expect(prompt).toContain("") + expect(prompt).toContain("Do not use `apply_patch`") }) test("generic GPT model uses generic GPT prompt", () => { @@ -521,6 +550,7 @@ describe("buildSisyphusJuniorPrompt", () => { expect(prompt).toContain("Scope Discipline") expect(prompt).toContain("") expect(prompt).toContain("Progress Updates") + expect(prompt).toContain("Do not use `apply_patch`") }) test("Claude model prompt contains Claude-specific sections", () => { diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index f534a50fa..059f080e2 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -492,6 +492,7 @@ export function createSisyphusAgent( permission: { question: "allow", call_omo_agent: "deny", + apply_patch: "deny", } as AgentConfig["permission"], reasoningEffort: "medium", }; @@ -531,6 +532,7 @@ export function createSisyphusAgent( const permission = { question: "allow", call_omo_agent: "deny", + ...(isGptModel(model) ? { apply_patch: "deny" as const } : {}), } as AgentConfig["permission"]; const base = { description: diff --git a/src/agents/sisyphus/gpt-5-4.ts b/src/agents/sisyphus/gpt-5-4.ts index 3dcb12d0d..c111aa2e2 100644 --- a/src/agents/sisyphus/gpt-5-4.ts +++ b/src/agents/sisyphus/gpt-5-4.ts @@ -304,7 +304,7 @@ Every implementation task follows this cycle. No exceptions. Skills: if ANY available skill's domain overlaps with the task, load it NOW via \`skill\` tool and include it in \`load_skills\`. When the connection is even remotely plausible, load the skill - the cost of loading an irrelevant skill is near zero, the cost of missing a relevant one is high. 4. EXECUTE_OR_SUPERVISE - - If self: surgical changes, match existing patterns, minimal diff. Never suppress type errors. Never commit unless asked. Bugfix rule: fix minimally, never refactor while fixing. + If self: surgical changes, match existing patterns, minimal diff. Never suppress type errors. Never commit unless asked. Bugfix rule: fix minimally, never refactor while fixing. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification. If delegated: exhaustive 6-section prompt per \`\` protocol. Session continuity for follow-ups. 5. VERIFY - diff --git a/src/agents/tool-restrictions.test.ts b/src/agents/tool-restrictions.test.ts index 85facdc54..3ae7bfcfe 100644 --- a/src/agents/tool-restrictions.test.ts +++ b/src/agents/tool-restrictions.test.ts @@ -5,6 +5,7 @@ import { createExploreAgent } from "./explore" import { createMomusAgent } from "./momus" import { createMetisAgent } from "./metis" import { createAtlasAgent } from "./atlas" +import { createSisyphusAgent } from "./sisyphus" const TEST_MODEL = "anthropic/claude-sonnet-4-5" @@ -111,4 +112,23 @@ describe("read-only agent tool restrictions", () => { expect(permission["call_omo_agent"]).toBeUndefined() }) }) + + describe("Sisyphus GPT variants", () => { + test("deny apply_patch for GPT models but not Claude models", () => { + // given + const gpt54Agent = createSisyphusAgent("openai/gpt-5.4") + const gptGenericAgent = createSisyphusAgent("openai/gpt-5.2") + const claudeAgent = createSisyphusAgent(TEST_MODEL) + + // when + const gpt54Permission = (gpt54Agent.permission ?? {}) as Record + const gptGenericPermission = (gptGenericAgent.permission ?? {}) as Record + const claudePermission = (claudeAgent.permission ?? {}) as Record + + // then + expect(gpt54Permission["apply_patch"]).toBe("deny") + expect(gptGenericPermission["apply_patch"]).toBe("deny") + expect(claudePermission["apply_patch"]).toBeUndefined() + }) + }) }) From ae3a8d628b91dfeaa012b73882db74e5f2b217ec Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 19:56:51 +0900 Subject: [PATCH 02/86] fix(delegate-task): tighten subagent depth guard + add regression smoke tests The depth limit (default maxDepth=3) was being silently bypassed when sync-task.ts could not reach the manager's spawn enforcement methods -- the fallback hardcoded childDepth: 1, allowing infinite recursion of delegate_task calls in degraded environments. This was hard to catch because: 1. The fallback path took the dangerous default silently (no log). 2. There were no end-to-end smoke tests asserting that the depth value coming back from reserveSubagentSpawn is actually used. 3. The unit tests for resolveSubagentSpawnContext only covered error cases, not the actual depth calculation. Changes: - sync-task.ts: split the spawnContext fallback into an explicit if/else with a WARNING log when the manager is missing enforcement methods. This makes the dangerous path observable in logs. - subagent-spawn-limits.test.ts: add depth calculation regression tests (root, depth-1, depth-2, depth at max, parent cycle detection). - sync-task.test.ts: add two regression smoke tests: 1. depth limit error from reserveSubagentSpawn must be propagated and must NOT create the session. 2. spawnDepth recorded in metadata must equal what reserveSubagentSpawn returns -- guards against silent fallback to childDepth: 1. 15 new spawn-limits tests + 2 new sync-task tests pass. Full suite: 5105 pass, 0 fail. --- .../subagent-spawn-limits.test.ts | 183 +++++++++++++++++- src/tools/delegate-task/sync-task.test.ts | 133 +++++++++++++ src/tools/delegate-task/sync-task.ts | 31 ++- 3 files changed, 338 insertions(+), 9 deletions(-) diff --git a/src/features/background-agent/subagent-spawn-limits.test.ts b/src/features/background-agent/subagent-spawn-limits.test.ts index 154718dbd..85824d46c 100644 --- a/src/features/background-agent/subagent-spawn-limits.test.ts +++ b/src/features/background-agent/subagent-spawn-limits.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "bun:test" import type { OpencodeClient } from "./constants" -import { resolveSubagentSpawnContext } from "./subagent-spawn-limits" +import { + resolveSubagentSpawnContext, + getMaxSubagentDepth, + DEFAULT_MAX_SUBAGENT_DEPTH, + createSubagentDepthLimitError, + createSubagentDescendantLimitError, + getMaxRootSessionSpawnBudget, + DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET, +} from "./subagent-spawn-limits" function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient { return { @@ -41,4 +49,177 @@ describe("resolveSubagentSpawnContext", () => { await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*No session data returned/) }) }) + + describe("depth calculation smoke tests (regression guard)", () => { + test("root session (no parentID) reports depth 0 and childDepth 1", async () => { + // given - a root session with no parent + const client = createMockClient(async (opts) => { + if (opts.path.id === "root-session") { + return { data: { id: "root-session", parentID: undefined } } + } + return { error: "not found", data: undefined } + }) + + // when + const result = await resolveSubagentSpawnContext(client, "root-session") + + // then + expect(result.rootSessionID).toBe("root-session") + expect(result.parentDepth).toBe(0) + expect(result.childDepth).toBe(1) + }) + + test("depth-1 child reports childDepth 2", async () => { + // given - child -> root chain + const client = createMockClient(async (opts) => { + if (opts.path.id === "child-1") { + return { data: { id: "child-1", parentID: "root-session" } } + } + if (opts.path.id === "root-session") { + return { data: { id: "root-session", parentID: undefined } } + } + return { error: "not found", data: undefined } + }) + + // when + const result = await resolveSubagentSpawnContext(client, "child-1") + + // then + expect(result.rootSessionID).toBe("root-session") + expect(result.parentDepth).toBe(1) + expect(result.childDepth).toBe(2) + }) + + test("depth-2 grandchild reports childDepth 3", async () => { + // given - grandchild -> child -> root chain + const client = createMockClient(async (opts) => { + const sessions: Record = { + "grandchild": { id: "grandchild", parentID: "child" }, + "child": { id: "child", parentID: "root" }, + "root": { id: "root", parentID: undefined }, + } + const session = sessions[opts.path.id] + if (session) return { data: session } + return { error: "not found", data: undefined } + }) + + // when + const result = await resolveSubagentSpawnContext(client, "grandchild") + + // then + expect(result.rootSessionID).toBe("root") + expect(result.parentDepth).toBe(2) + expect(result.childDepth).toBe(3) + }) + + test("depth at DEFAULT_MAX_SUBAGENT_DEPTH reports exact max childDepth", async () => { + // given - chain of exactly DEFAULT_MAX_SUBAGENT_DEPTH depth + // With default=3: session-3 -> session-2 -> session-1 -> root + const sessions: Record = { + "root": { id: "root" }, + } + for (let i = 1; i <= DEFAULT_MAX_SUBAGENT_DEPTH; i++) { + sessions[`session-${i}`] = { + id: `session-${i}`, + parentID: i === 1 ? "root" : `session-${i - 1}`, + } + } + + const client = createMockClient(async (opts) => { + const session = sessions[opts.path.id] + if (session) return { data: session } + return { error: "not found", data: undefined } + }) + + // when - resolve from the deepest session + const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}` + const result = await resolveSubagentSpawnContext(client, deepest) + + // then - childDepth should be DEFAULT_MAX_SUBAGENT_DEPTH + 1 (exceeds limit) + expect(result.childDepth).toBe(DEFAULT_MAX_SUBAGENT_DEPTH + 1) + expect(result.parentDepth).toBe(DEFAULT_MAX_SUBAGENT_DEPTH) + }) + + test("detects parent cycle and throws", async () => { + // given - A -> B -> A (cycle) + const client = createMockClient(async (opts) => { + const sessions: Record = { + "session-a": { id: "session-a", parentID: "session-b" }, + "session-b": { id: "session-b", parentID: "session-a" }, + } + const session = sessions[opts.path.id] + if (session) return { data: session } + return { error: "not found", data: undefined } + }) + + // when + const result = resolveSubagentSpawnContext(client, "session-a") + + // then + await expect(result).rejects.toThrow(/session parent cycle/) + }) + }) +}) + +describe("getMaxSubagentDepth", () => { + test("returns DEFAULT_MAX_SUBAGENT_DEPTH when no config", () => { + expect(getMaxSubagentDepth()).toBe(DEFAULT_MAX_SUBAGENT_DEPTH) + expect(getMaxSubagentDepth(undefined)).toBe(DEFAULT_MAX_SUBAGENT_DEPTH) + }) + + test("returns config.maxDepth when provided", () => { + expect(getMaxSubagentDepth({ maxDepth: 5 })).toBe(5) + expect(getMaxSubagentDepth({ maxDepth: 1 })).toBe(1) + expect(getMaxSubagentDepth({ maxDepth: 0 })).toBe(0) + }) + + test("default is 3", () => { + expect(DEFAULT_MAX_SUBAGENT_DEPTH).toBe(3) + }) +}) + +describe("getMaxRootSessionSpawnBudget", () => { + test("returns DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET when no config", () => { + expect(getMaxRootSessionSpawnBudget()).toBe(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET) + }) + + test("returns config.maxDescendants when provided", () => { + expect(getMaxRootSessionSpawnBudget({ maxDescendants: 10 })).toBe(10) + }) + + test("default is 50", () => { + expect(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET).toBe(50) + }) +}) + +describe("createSubagentDepthLimitError", () => { + test("includes childDepth, maxDepth, and session IDs in message", () => { + const error = createSubagentDepthLimitError({ + childDepth: 4, + maxDepth: 3, + parentSessionID: "parent-123", + rootSessionID: "root-456", + }) + + expect(error.message).toContain("child depth 4") + expect(error.message).toContain("maxDepth=3") + expect(error.message).toContain("parent-123") + expect(error.message).toContain("root-456") + expect(error.message).toContain("spawn blocked") + }) +}) + +describe("createSubagentDescendantLimitError", () => { + test("includes descendant count, max, and root session ID", () => { + const error = createSubagentDescendantLimitError({ + rootSessionID: "root-789", + descendantCount: 50, + maxDescendants: 50, + }) + + expect(error.message).toContain("root-789") + expect(error.message).toContain("50") + expect(error.message).toContain("maxDescendants=50") + expect(error.message).toContain("spawn blocked") + }) }) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 483a826b9..b34a3cc6c 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -282,6 +282,139 @@ describe("executeSyncTask - cleanup on error paths", () => { expect(deleteCalls.length).toBe(1) expect(deleteCalls[0]).toBe("ses_test_12345678") }) + + test("depth regression: blocks spawn when reserveSubagentSpawn throws depth limit error", async () => { + // This is a smoke test guarding against regressions where the depth limit + // would be silently bypassed (e.g. via a fallback path that hardcodes + // childDepth: 1). + + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + const reserveSubagentSpawn = mock(async () => { + throw new Error( + "Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3. Parent session: parent. Root session: root. Continue in an existing subagent session instead of spawning another." + ) + }) + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + manager: { reserveSubagentSpawn }, + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when - executeSyncTask is called from a session at max depth + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then - should propagate the depth limit error and NOT create the session + expect(result).toContain("Subagent spawn blocked") + expect(result).toContain("child depth 4") + expect(result).toContain("maxDepth=3") + expect(reserveSubagentSpawn).toHaveBeenCalledWith("parent-session") + // critical: createSyncSession must NOT have been called -- if it was, + // the depth guard was bypassed. + expect(addCalls.length).toBe(0) + }) + + test("depth regression: does not silently fall back to childDepth: 1 when manager methods are present", async () => { + // Guards against the dangerous fallback path in sync-task.ts that + // hardcodes childDepth: 1 if reserveSubagentSpawn / assertCanSpawn are + // not functions. With a real manager present, the fallback must NOT be + // taken. + + const mockClient = { + session: { + create: async () => ({ data: { id: "ses_test_12345678" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + + let reservedDepth: number | undefined + const commit = mock(() => 1) + const rollback = mock(() => {}) + const reserveSubagentSpawn = mock(async () => { + // Return a depth that proves the real manager was consulted + reservedDepth = 3 + return { + spawnContext: { rootSessionID: "root", parentDepth: 2, childDepth: 3 }, + descendantCount: 5, + commit, + rollback, + } + }) + + const deps = { + createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }), + sendSyncPrompt: async () => null, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "Result" }), + } + + const metadataCalls: any[] = [] + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: (input: any) => { metadataCalls.push(input) }, + } + + const mockExecutorCtx = { + manager: { reserveSubagentSpawn }, + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + } + + const args = { + prompt: "test prompt", + description: "test task", + category: "test", + load_skills: [], + run_in_background: false, + command: null, + } + + //#when + await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "test-agent", undefined, undefined, undefined, undefined, deps) + + //#then - the spawnDepth recorded in metadata MUST match what reserveSubagentSpawn returned + expect(reservedDepth).toBe(3) + const taskMeta = metadataCalls.find((c) => c.metadata?.spawnDepth !== undefined) + expect(taskMeta).toBeDefined() + expect(taskMeta.metadata.spawnDepth).toBe(3) // NOT 1 (the fallback value) + }) }) export {} diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 07372d8a0..d1ee6ff31 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -37,14 +37,29 @@ export async function executeSyncTask( spawnReservation = await manager.reserveSubagentSpawn(parentContext.sessionID) } - const spawnContext = spawnReservation?.spawnContext - ?? (typeof manager?.assertCanSpawn === "function" - ? await manager.assertCanSpawn(parentContext.sessionID) - : { - rootSessionID: parentContext.sessionID, - parentDepth: 0, - childDepth: 1, - }) + // Depth/descendant guard. We must NOT silently fall back to childDepth: 1 + // when the manager is unavailable or lacks the spawn methods, because that + // would let subagents recurse without bound. The only safe fallback is + // when the manager genuinely cannot enforce limits (legacy SDK), in which + // case we still record childDepth: 1 but log a warning so regressions are + // visible. + let spawnContext: { rootSessionID: string; parentDepth: number; childDepth: number } + if (spawnReservation?.spawnContext) { + spawnContext = spawnReservation.spawnContext + } else if (typeof manager?.assertCanSpawn === "function") { + spawnContext = await manager.assertCanSpawn(parentContext.sessionID) + } else { + log( + "[task] WARNING: BackgroundManager has no spawn enforcement methods (reserveSubagentSpawn / assertCanSpawn). " + + "Depth and descendant limits cannot be enforced for this task. This indicates an old SDK or a misconfiguration.", + { parentSessionID: parentContext.sessionID } + ) + spawnContext = { + rootSessionID: parentContext.sessionID, + parentDepth: 0, + childDepth: 1, + } + } const createSessionResult = await deps.createSyncSession(client, { parentSessionID: parentContext.sessionID, From 76239972c210c63b7f62023dbdf91f40d21f8ed5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 20:24:03 +0900 Subject: [PATCH 03/86] fix(doctor): include custom providers from opencode.json in provider check The doctor's 'Model override uses unavailable provider' check only looked at providers from ~/.cache/opencode/models.json (built-in providers from models.dev). Custom OpenAI-compatible providers defined in the user's opencode.json (under the 'provider' key) were not included, causing false-positive warnings. Now loadAvailableModelsFromCache() also reads provider names from ~/.config/opencode/opencode.json and ~/.config/opencode/opencode.jsonc, merging them with the cache providers. This eliminates the false positive while preserving real warnings for truly unknown providers. 7 new tests cover: cache-only, custom-only, merged, deduplicated, JSONC variant, and malformed config resilience. Fixes #3199 --- .../checks/model-resolution-cache.test.ts | 150 ++++++++++++++++++ .../doctor/checks/model-resolution-cache.ts | 50 +++++- 2 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/cli/doctor/checks/model-resolution-cache.test.ts diff --git a/src/cli/doctor/checks/model-resolution-cache.test.ts b/src/cli/doctor/checks/model-resolution-cache.test.ts new file mode 100644 index 000000000..df6d68f16 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-cache.test.ts @@ -0,0 +1,150 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, writeFileSync, rmSync } from "node:fs" +import { join } from "node:path" +import { loadAvailableModelsFromCache } from "./model-resolution-cache" + +describe("loadAvailableModelsFromCache", () => { + const originalXDGCache = process.env.XDG_CACHE_HOME + const originalXDGConfig = process.env.XDG_CONFIG_HOME + let tempDir: string + + beforeEach(() => { + tempDir = join("/tmp", `doctor-cache-test-${Date.now()}`) + mkdirSync(join(tempDir, "cache", "opencode"), { recursive: true }) + mkdirSync(join(tempDir, "config", "opencode"), { recursive: true }) + process.env.XDG_CACHE_HOME = join(tempDir, "cache") + process.env.XDG_CONFIG_HOME = join(tempDir, "config") + }) + + afterEach(() => { + process.env.XDG_CACHE_HOME = originalXDGCache + process.env.XDG_CONFIG_HOME = originalXDGConfig + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("returns cacheExists: false when no models.json and no custom providers", () => { + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(false) + expect(result.providers).toEqual([]) + expect(result.modelCount).toBe(0) + }) + + test("reads providers from models.json cache", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ + openai: { models: { "gpt-5.4": {} } }, + anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } }, + }) + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) + expect(result.providers).toContain("openai") + expect(result.providers).toContain("anthropic") + expect(result.modelCount).toBe(3) + }) + + test("includes custom providers from opencode.json even if not in cache", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ + openai: { models: { "gpt-5.4": {} } }, + }) + ) + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + JSON.stringify({ + provider: { + "openai-custom": { + npm: "@ai-sdk/openai-compatible", + models: { "gpt-5.4": {} }, + }, + "my-local-llm": { + npm: "@ai-sdk/openai-compatible", + models: { "local-model": {} }, + }, + }, + }) + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) + expect(result.providers).toContain("openai") + expect(result.providers).toContain("openai-custom") + expect(result.providers).toContain("my-local-llm") + }) + + test("deduplicates providers that appear in both cache and opencode.json", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ + openai: { models: { "gpt-5.4": {} } }, + }) + ) + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + JSON.stringify({ + provider: { + openai: { models: { "custom-model": {} } }, + }, + }) + ) + + const result = loadAvailableModelsFromCache() + const openaiCount = result.providers.filter((p) => p === "openai").length + expect(openaiCount).toBe(1) + }) + + test("returns custom providers even without models.json cache", () => { + // No models.json exists + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + JSON.stringify({ + provider: { + "openai-custom": { + npm: "@ai-sdk/openai-compatible", + models: { "gpt-5.4": {} }, + }, + }, + }) + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) // custom providers make it effectively "exists" + expect(result.providers).toContain("openai-custom") + }) + + test("reads from opencode.jsonc (JSONC variant)", () => { + writeFileSync( + join(tempDir, "config", "opencode", "opencode.jsonc"), + `{ + // This is a comment + "provider": { + "my-provider": { + "models": { "test-model": {} } + } + } + }` + ) + + const result = loadAvailableModelsFromCache() + expect(result.providers).toContain("my-provider") + }) + + test("ignores malformed opencode.json gracefully", () => { + writeFileSync( + join(tempDir, "cache", "opencode", "models.json"), + JSON.stringify({ openai: { models: { "gpt-5.4": {} } } }) + ) + writeFileSync( + join(tempDir, "config", "opencode", "opencode.json"), + "this is not valid json {{{", + ) + + const result = loadAvailableModelsFromCache() + expect(result.cacheExists).toBe(true) + expect(result.providers).toContain("openai") + // Should not crash, just skip the config + }) +}) diff --git a/src/cli/doctor/checks/model-resolution-cache.ts b/src/cli/doctor/checks/model-resolution-cache.ts index 7c1b75233..d1e82cc3e 100644 --- a/src/cli/doctor/checks/model-resolution-cache.ts +++ b/src/cli/doctor/checks/model-resolution-cache.ts @@ -10,10 +10,51 @@ function getOpenCodeCacheDir(): string { return join(homedir(), ".cache", "opencode") } +function getOpenCodeConfigDir(): string { + const xdgConfig = process.env.XDG_CONFIG_HOME + if (xdgConfig) return join(xdgConfig, "opencode") + return join(homedir(), ".config", "opencode") +} + +/** + * Read custom provider names from opencode.json configs. + * Custom providers defined in the user's opencode.json (under the "provider" key) + * are valid at runtime but don't appear in the model cache (models.json), which + * only contains built-in providers from models.dev. This causes false-positive + * warnings in doctor. + */ +function loadCustomProviderNames(): string[] { + const configDir = getOpenCodeConfigDir() + const candidatePaths = [ + join(configDir, "opencode.json"), + join(configDir, "opencode.jsonc"), + ] + + for (const configPath of candidatePaths) { + if (!existsSync(configPath)) continue + try { + const content = readFileSync(configPath, "utf-8") + const data = parseJsonc<{ provider?: Record }>(content) + if (data?.provider && typeof data.provider === "object") { + return Object.keys(data.provider) + } + } catch { + // ignore parse errors + } + } + + return [] +} + export function loadAvailableModelsFromCache(): AvailableModelsInfo { const cacheFile = join(getOpenCodeCacheDir(), "models.json") + const customProviders = loadCustomProviderNames() if (!existsSync(cacheFile)) { + // Even without the cache, custom providers are valid + if (customProviders.length > 0) { + return { providers: customProviders, modelCount: 0, cacheExists: true } + } return { providers: [], modelCount: 0, cacheExists: false } } @@ -21,16 +62,19 @@ export function loadAvailableModelsFromCache(): AvailableModelsInfo { const content = readFileSync(cacheFile, "utf-8") const data = parseJsonc }>>(content) - const providers = Object.keys(data) + const cacheProviders = Object.keys(data) let modelCount = 0 - for (const providerId of providers) { + for (const providerId of cacheProviders) { const models = data[providerId]?.models if (models && typeof models === "object") { modelCount += Object.keys(models).length } } - return { providers, modelCount, cacheExists: true } + // Merge cache providers with custom providers from opencode.json + const allProviders = [...new Set([...cacheProviders, ...customProviders])] + + return { providers: allProviders, modelCount, cacheExists: true } } catch { return { providers: [], modelCount: 0, cacheExists: false } } From f4eabf9f0e91e87f0ab6686541035dee06a51c2b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 12:57:45 +0900 Subject: [PATCH 04/86] chore(deps): upgrade @opencode-ai/{plugin,sdk} to 1.4.0 and restore zod v4 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- assets/oh-my-opencode.schema.json | 6069 ----------------------------- bun.lock | 62 +- package.json | 33 +- 3 files changed, 32 insertions(+), 6132 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index b5310229e..62974a750 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -1,6074 +1,5 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "new_task_system_enabled": { - "type": "boolean" - }, - "default_run_agent": { - "type": "string" - }, - "disabled_mcps": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - }, - "disabled_agents": { - "type": "array", - "items": { - "type": "string" - } - }, - "disabled_skills": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "playwright", - "agent-browser", - "dev-browser", - "frontend-ui-ux", - "git-master", - "review-work", - "ai-slop-remover" - ] - } - }, - "disabled_hooks": { - "type": "array", - "items": { - "type": "string" - } - }, - "disabled_commands": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "init-deep", - "ralph-loop", - "ulw-loop", - "cancel-ralph", - "refactor", - "start-work", - "stop-continuation", - "remove-ai-slops" - ] - } - }, - "disabled_tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "mcp_env_allowlist": { - "type": "array", - "items": { - "type": "string" - } - }, - "hashline_edit": { - "type": "boolean" - }, - "model_fallback": { - "type": "boolean" - }, - "agents": { - "type": "object", - "properties": { - "build": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "plan": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "sisyphus": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "hephaestus": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "allow_non_gpt_model": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "sisyphus-junior": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "OpenCode-Builder": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "prometheus": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "metis": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "momus": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "oracle": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "librarian": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "explore": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "multimodal-looker": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "atlas": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "categories": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - }, - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - }, - "required": [ - "model" - ], - "additionalProperties": false - } - ] - } - } - ] - }, - "variant": { - "type": "string" - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "prompt_append": { - "type": "string" - }, - "max_prompt_tokens": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "is_unstable_agent": { - "type": "boolean" - }, - "disable": { - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "claude_code": { - "type": "object", - "properties": { - "mcp": { - "type": "boolean" - }, - "commands": { - "type": "boolean" - }, - "skills": { - "type": "boolean" - }, - "agents": { - "type": "boolean" - }, - "hooks": { - "type": "boolean" - }, - "plugins": { - "type": "boolean" - }, - "plugins_override": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - } - }, - "additionalProperties": false - }, - "sisyphus_agent": { - "type": "object", - "properties": { - "disabled": { - "type": "boolean" - }, - "default_builder_enabled": { - "type": "boolean" - }, - "planner_enabled": { - "type": "boolean" - }, - "replace_plan": { - "type": "boolean" - }, - "tdd": { - "default": true, - "type": "boolean" - } - }, - "additionalProperties": false - }, - "comment_checker": { - "type": "object", - "properties": { - "custom_prompt": { - "type": "string" - } - }, - "additionalProperties": false - }, - "experimental": { - "type": "object", - "properties": { - "aggressive_truncation": { - "type": "boolean" - }, - "auto_resume": { - "type": "boolean" - }, - "preemptive_compaction": { - "type": "boolean" - }, - "truncate_all_tool_outputs": { - "type": "boolean" - }, - "dynamic_context_pruning": { - "type": "object", - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "notification": { - "default": "detailed", - "type": "string", - "enum": [ - "off", - "minimal", - "detailed" - ] - }, - "turn_protection": { - "type": "object", - "properties": { - "enabled": { - "default": true, - "type": "boolean" - }, - "turns": { - "default": 3, - "type": "number", - "minimum": 1, - "maximum": 10 - } - }, - "required": [ - "enabled", - "turns" - ], - "additionalProperties": false - }, - "protected_tools": { - "default": [ - "task", - "todowrite", - "todoread", - "lsp_rename", - "session_read", - "session_write", - "session_search" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "strategies": { - "type": "object", - "properties": { - "deduplication": { - "type": "object", - "properties": { - "enabled": { - "default": true, - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "additionalProperties": false - }, - "supersede_writes": { - "type": "object", - "properties": { - "enabled": { - "default": true, - "type": "boolean" - }, - "aggressive": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "enabled", - "aggressive" - ], - "additionalProperties": false - }, - "purge_errors": { - "type": "object", - "properties": { - "enabled": { - "default": true, - "type": "boolean" - }, - "turns": { - "default": 5, - "type": "number", - "minimum": 1, - "maximum": 20 - } - }, - "required": [ - "enabled", - "turns" - ], - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "required": [ - "enabled", - "notification", - "protected_tools" - ], - "additionalProperties": false - }, - "task_system": { - "type": "boolean" - }, - "plugin_load_timeout_ms": { - "type": "number", - "minimum": 1000 - }, - "safe_hook_creation": { - "type": "boolean" - }, - "disable_omo_env": { - "type": "boolean" - }, - "hashline_edit": { - "type": "boolean" - }, - "model_fallback_title": { - "type": "boolean" - }, - "max_tools": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - } - }, - "additionalProperties": false - }, - "auto_update": { - "type": "boolean" - }, - "skills": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object", - "properties": { - "sources": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "recursive": { - "type": "boolean" - }, - "glob": { - "type": "string" - } - }, - "required": [ - "path" - ], - "additionalProperties": false - } - ] - } - }, - "enable": { - "type": "array", - "items": { - "type": "string" - } - }, - "disable": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "template": { - "type": "string" - }, - "from": { - "type": "string" - }, - "model": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "subtask": { - "type": "boolean" - }, - "argument-hint": { - "type": "string" - }, - "license": { - "type": "string" - }, - "compatibility": { - "type": "string" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "allowed-tools": { - "type": "array", - "items": { - "type": "string" - } - }, - "disable": { - "type": "boolean" - } - }, - "additionalProperties": false - } - ] - } - } - ] - }, - "ralph_loop": { - "type": "object", - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "default_max_iterations": { - "default": 100, - "type": "number", - "minimum": 1, - "maximum": 1000 - }, - "state_dir": { - "type": "string" - }, - "default_strategy": { - "default": "continue", - "type": "string", - "enum": [ - "reset", - "continue" - ] - } - }, - "required": [ - "enabled", - "default_max_iterations", - "default_strategy" - ], - "additionalProperties": false - }, - "runtime_fallback": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "retry_on_errors": { - "type": "array", - "items": { - "type": "number" - } - }, - "max_fallback_attempts": { - "type": "number", - "minimum": 1, - "maximum": 20 - }, - "cooldown_seconds": { - "type": "number", - "minimum": 0 - }, - "timeout_seconds": { - "type": "number", - "minimum": 0 - }, - "notify_on_fallback": { - "type": "boolean" - } - }, - "additionalProperties": false - } - ] - }, - "background_task": { - "type": "object", - "properties": { - "defaultConcurrency": { - "type": "number", - "minimum": 1 - }, - "providerConcurrency": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "number", - "minimum": 0 - } - }, - "modelConcurrency": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "number", - "minimum": 0 - } - }, - "maxDepth": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "maxDescendants": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 - }, - "staleTimeoutMs": { - "type": "number", - "minimum": 60000 - }, - "messageStalenessTimeoutMs": { - "type": "number", - "minimum": 60000 - }, - "taskTtlMs": { - "type": "number", - "minimum": 300000 - }, - "sessionGoneTimeoutMs": { - "type": "number", - "minimum": 10000 - }, - "syncPollTimeoutMs": { - "type": "number", - "minimum": 60000 - }, - "maxToolCalls": { - "type": "integer", - "minimum": 10, - "maximum": 9007199254740991 - }, - "circuitBreaker": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "maxToolCalls": { - "type": "integer", - "minimum": 10, - "maximum": 9007199254740991 - }, - "consecutiveThreshold": { - "type": "integer", - "minimum": 5, - "maximum": 9007199254740991 - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "notification": { - "type": "object", - "properties": { - "force_enable": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "model_capabilities": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "auto_refresh_on_start": { - "type": "boolean" - }, - "refresh_timeout_ms": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "source_url": { - "type": "string", - "format": "uri" - } - }, - "additionalProperties": false - }, - "openclaw": { - "type": "object", - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "gateways": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "type": { - "default": "http", - "type": "string", - "enum": [ - "http", - "command" - ] - }, - "url": { - "type": "string" - }, - "method": { - "default": "POST", - "type": "string" - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "command": { - "type": "string" - }, - "timeout": { - "type": "number" - } - }, - "required": [ - "type", - "method" - ], - "additionalProperties": false - } - }, - "hooks": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "enabled": { - "default": true, - "type": "boolean" - }, - "gateway": { - "type": "string" - }, - "instruction": { - "type": "string" - } - }, - "required": [ - "enabled", - "gateway", - "instruction" - ], - "additionalProperties": false - } - }, - "replyListener": { - "type": "object", - "properties": { - "discordBotToken": { - "type": "string" - }, - "discordChannelId": { - "type": "string" - }, - "discordMention": { - "type": "string" - }, - "authorizedDiscordUserIds": { - "default": [], - "type": "array", - "items": { - "type": "string" - } - }, - "telegramBotToken": { - "type": "string" - }, - "telegramChatId": { - "type": "string" - }, - "pollIntervalMs": { - "default": 3000, - "type": "number" - }, - "rateLimitPerMinute": { - "default": 10, - "type": "number" - }, - "maxMessageLength": { - "default": 500, - "type": "number" - }, - "includePrefix": { - "default": true, - "type": "boolean" - } - }, - "required": [ - "authorizedDiscordUserIds", - "pollIntervalMs", - "rateLimitPerMinute", - "maxMessageLength", - "includePrefix" - ], - "additionalProperties": false - } - }, - "required": [ - "enabled", - "gateways", - "hooks" - ], - "additionalProperties": false - }, - "babysitting": { - "type": "object", - "properties": { - "timeout_ms": { - "default": 120000, - "type": "number" - } - }, - "required": [ - "timeout_ms" - ], - "additionalProperties": false - }, - "git_master": { - "default": { - "commit_footer": true, - "include_co_authored_by": true, - "git_env_prefix": "GIT_MASTER=1" - }, - "type": "object", - "properties": { - "commit_footer": { - "default": true, - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string" - } - ] - }, - "include_co_authored_by": { - "default": true, - "type": "boolean" - }, - "git_env_prefix": { - "default": "GIT_MASTER=1", - "type": "string" - } - }, - "required": [ - "commit_footer", - "include_co_authored_by", - "git_env_prefix" - ], - "additionalProperties": false - }, - "browser_automation_engine": { - "type": "object", - "properties": { - "provider": { - "default": "playwright", - "type": "string", - "enum": [ - "playwright", - "agent-browser", - "dev-browser", - "playwright-cli" - ] - } - }, - "required": [ - "provider" - ], - "additionalProperties": false - }, - "websearch": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "enum": [ - "exa", - "tavily" - ] - } - }, - "additionalProperties": false - }, - "tmux": { - "type": "object", - "properties": { - "enabled": { - "default": false, - "type": "boolean" - }, - "layout": { - "default": "main-vertical", - "type": "string", - "enum": [ - "main-horizontal", - "main-vertical", - "tiled", - "even-horizontal", - "even-vertical" - ] - }, - "main_pane_size": { - "default": 60, - "type": "number", - "minimum": 20, - "maximum": 80 - }, - "main_pane_min_width": { - "default": 120, - "type": "number", - "minimum": 40 - }, - "agent_pane_min_width": { - "default": 40, - "type": "number", - "minimum": 20 - }, - "isolation": { - "default": "inline", - "type": "string", - "enum": [ - "inline", - "window", - "session" - ] - } - }, - "required": [ - "enabled", - "layout", - "main_pane_size", - "main_pane_min_width", - "agent_pane_min_width", - "isolation" - ], - "additionalProperties": false - }, - "sisyphus": { - "type": "object", - "properties": { - "tasks": { - "type": "object", - "properties": { - "storage_path": { - "type": "string" - }, - "task_list_id": { - "type": "string" - }, - "claude_code_compat": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "claude_code_compat" - ], - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "start_work": { - "type": "object", - "properties": { - "auto_commit": { - "default": true, - "type": "boolean" - } - }, - "required": [ - "auto_commit" - ], - "additionalProperties": false - }, - "_migrations": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "git_master" - ], - "additionalProperties": false, "$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "title": "Oh My OpenCode Configuration", "description": "Configuration schema for oh-my-opencode plugin" diff --git a/bun.lock b/bun.lock index ef331fc85..b4e9f12f5 100644 --- a/bun.lock +++ b/bun.lock @@ -10,8 +10,8 @@ "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", - "@opencode-ai/plugin": "^1.2.24", - "@opencode-ai/sdk": "^1.2.24", + "@opencode-ai/plugin": "^1.4.0", + "@opencode-ai/sdk": "^1.4.0", "commander": "^14.0.2", "detect-libc": "^2.0.0", "diff": "^8.0.3", @@ -20,8 +20,7 @@ "picocolors": "^1.1.1", "picomatch": "^4.0.2", "vscode-jsonrpc": "^8.2.0", - "zod": "^3.24.0", - "zod-to-json-schema": "^3.25.1", + "zod": "^4.3.0", }, "devDependencies": { "@types/js-yaml": "^4.0.9", @@ -30,17 +29,17 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.15.3", - "oh-my-opencode-darwin-x64": "3.15.3", - "oh-my-opencode-darwin-x64-baseline": "3.15.3", - "oh-my-opencode-linux-arm64": "3.15.3", - "oh-my-opencode-linux-arm64-musl": "3.15.3", - "oh-my-opencode-linux-x64": "3.15.3", - "oh-my-opencode-linux-x64-baseline": "3.15.3", - "oh-my-opencode-linux-x64-musl": "3.15.3", - "oh-my-opencode-linux-x64-musl-baseline": "3.15.3", - "oh-my-opencode-windows-x64": "3.15.3", - "oh-my-opencode-windows-x64-baseline": "3.15.3", + "oh-my-opencode-darwin-arm64": "3.16.0", + "oh-my-opencode-darwin-x64": "3.16.0", + "oh-my-opencode-darwin-x64-baseline": "3.16.0", + "oh-my-opencode-linux-arm64": "3.16.0", + "oh-my-opencode-linux-arm64-musl": "3.16.0", + "oh-my-opencode-linux-x64": "3.16.0", + "oh-my-opencode-linux-x64-baseline": "3.16.0", + "oh-my-opencode-linux-x64-musl": "3.16.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.16.0", + "oh-my-opencode-windows-x64": "3.16.0", + "oh-my-opencode-windows-x64-baseline": "3.16.0", }, }, }, @@ -49,9 +48,6 @@ "@ast-grep/napi", "@code-yeongyu/comment-checker", ], - "overrides": { - "@opencode-ai/sdk": "^1.2.24", - }, "packages": { "@ast-grep/cli": ["@ast-grep/cli@0.41.1", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.41.1", "@ast-grep/cli-darwin-x64": "0.41.1", "@ast-grep/cli-linux-arm64-gnu": "0.41.1", "@ast-grep/cli-linux-x64-gnu": "0.41.1", "@ast-grep/cli-win32-arm64-msvc": "0.41.1", "@ast-grep/cli-win32-ia32-msvc": "0.41.1", "@ast-grep/cli-win32-x64-msvc": "0.41.1" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-6oSuzF1Ra0d9jdcmflRIR1DHcicI7TYVxaaV/hajV51J49r6C+1BA2H9G+e47lH4sDEXUS9KWLNGNvXa/Gqs5A=="], @@ -99,9 +95,9 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.24", "", { "dependencies": { "@opencode-ai/sdk": "1.2.24", "zod": "4.1.8" } }, "sha512-B3hw415D+2w6AtdRdvKWkuQVT0LXDWTdnAZhZC6gbd+UHh5O5DMmnZTe/YM8yK8ZZO9Dvo5rnV78TdDDYunJiw=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.4.0", "", { "dependencies": { "@opencode-ai/sdk": "1.4.0", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.97", "@opentui/solid": ">=0.1.97" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-VFIff6LHp/RVaJdrK3EQ1ijx0K1tV5i1DY5YJ+pRqwC6trunPHbvqSN0GHSTZX39RdnSc+XuzCTZQCy1W2qNOg=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.24", "", {}, "sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-mfa3MzhqNM+Az4bgPDDXL3NdG+aYOHClXmT6/4qLxf2ulyfPpMNHqb9Dfmo4D8UfmrDsPuJHmbune73/nUQnuw=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], @@ -239,28 +235,6 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.15.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-FApyQE45gv3VFwS/7iLS1/84v4iTX6BIVNcYYU2faqPazcZkvenkMbtxuWRfohQyZ1lhADopnjUcqOdcKjLDGQ=="], - - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-h4fr0/myoyvvytdizfLNQgRAWK+hw+1tW32rgL7ENLv1JQ8ChXHnHKEQ2saEqGfn1SuXvA0xUTsFMYR8q3mnbA=="], - - "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.15.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zhi5xGcEhirHcx95kZtABYlIdSt6a5L5+T+exR4Kcnu+KR1mJ6li9n3UBIiW8eVgDz2ls7W25ePD78xRlqnxlg=="], - - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-+lDsQMPfXGCrwe9vqHdmp1tCJ8PV+5OkKueVorRwXNfiZNOW3848TKxtW3QdkKopiBKejEaDfyu/IGSgWQ/iyQ=="], - - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.15.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-cokhNYK+dBVPRmZ2bYd3ZNp7dSGZdko77qUaeb0jjALFWkNzzmFgOV0spgOGZ3iS+yMS1XjAheTo5Qswh0capQ=="], - - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-8+57NMUwdcc2DZGX6KlNb1EchTB6xmwiiHcRhFZpYiAB1GCUFNeWihq3D7r5GUtOs0zQYWUT/F1Rj2nzBxuy+A=="], - - "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1awTpjU8m1cLF+GiiT7BuK5+y+WvTZwAaBZzYrJBzldiqdqMGJVYaH/uLiKt6CdZ0T6jh0zR/v85VFZIaXRusQ=="], - - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-WhmJ9ZwXxe3Nv0sVnFN3ibykie1JDiXthOmErhtKbcAL9V25IDYSbTcjxY2jUq0rNr4PeTvBva+WkMW4k9438w=="], - - "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.15.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Gx2YitS/Ydg1XdwZMAH186ABvHGPlnuVA/1j7nGdARIwNM/xz6bZRq+kaeMmlj2N1U63unMOHe1ibE6nL1oZSw=="], - - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-Q6xskcBlBqUT77OK+7oHID9McrHu6t5+P/YCaDU/zLvr1T8M0Z5WgakM5hRsqCI8e4P1NEX6wHtwQNbVfUgo1w=="], - - "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.15.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-2BlXtH+DrSRPFGEOtfY1mlROOXFeWQbG/EpDw0JD27s7QQOkShaDff8Vc48PnmD1H8vW4d7/o/eP8jJPPjGQ0w=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -331,12 +305,10 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/package.json b/package.json index 7cf39b8a0..f6b24b13a 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,8 @@ "@clack/prompts": "^0.11.0", "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", - "@opencode-ai/plugin": "^1.2.24", - "@opencode-ai/sdk": "^1.2.24", + "@opencode-ai/plugin": "^1.4.0", + "@opencode-ai/sdk": "^1.4.0", "commander": "^14.0.2", "detect-libc": "^2.0.0", "diff": "^8.0.3", @@ -69,8 +69,7 @@ "picocolors": "^1.1.1", "picomatch": "^4.0.2", "vscode-jsonrpc": "^8.2.0", - "zod-to-json-schema": "^3.25.1", - "zod": "^3.24.0" + "zod": "^4.3.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", @@ -79,21 +78,19 @@ "typescript": "^5.7.3" }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.15.3", - "oh-my-opencode-darwin-x64": "3.15.3", - "oh-my-opencode-darwin-x64-baseline": "3.15.3", - "oh-my-opencode-linux-arm64": "3.15.3", - "oh-my-opencode-linux-arm64-musl": "3.15.3", - "oh-my-opencode-linux-x64": "3.15.3", - "oh-my-opencode-linux-x64-baseline": "3.15.3", - "oh-my-opencode-linux-x64-musl": "3.15.3", - "oh-my-opencode-linux-x64-musl-baseline": "3.15.3", - "oh-my-opencode-windows-x64": "3.15.3", - "oh-my-opencode-windows-x64-baseline": "3.15.3" - }, - "overrides": { - "@opencode-ai/sdk": "^1.2.24" + "oh-my-opencode-darwin-arm64": "3.16.0", + "oh-my-opencode-darwin-x64": "3.16.0", + "oh-my-opencode-darwin-x64-baseline": "3.16.0", + "oh-my-opencode-linux-arm64": "3.16.0", + "oh-my-opencode-linux-arm64-musl": "3.16.0", + "oh-my-opencode-linux-x64": "3.16.0", + "oh-my-opencode-linux-x64-baseline": "3.16.0", + "oh-my-opencode-linux-x64-musl": "3.16.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.16.0", + "oh-my-opencode-windows-x64": "3.16.0", + "oh-my-opencode-windows-x64-baseline": "3.16.0" }, + "overrides": {}, "trustedDependencies": [ "@ast-grep/cli", "@ast-grep/napi", From 18771c8d6d127fb5ea2c6d92dbe4c1870bc9b57d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 12:59:54 +0900 Subject: [PATCH 05/86] fix(schema): restore z.toJSONSchema native v4 API --- assets/oh-my-opencode.schema.json | 6071 ++++++++++++++++++++++++++++- script/build-schema-document.ts | 9 +- 2 files changed, 6076 insertions(+), 4 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 62974a750..607988931 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2,5 +2,6074 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "title": "Oh My OpenCode Configuration", - "description": "Configuration schema for oh-my-opencode plugin" + "description": "Configuration schema for oh-my-opencode plugin", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "new_task_system_enabled": { + "type": "boolean" + }, + "default_run_agent": { + "type": "string" + }, + "disabled_mcps": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "disabled_agents": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled_skills": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "playwright", + "agent-browser", + "dev-browser", + "frontend-ui-ux", + "git-master", + "review-work", + "ai-slop-remover" + ] + } + }, + "disabled_hooks": { + "type": "array", + "items": { + "type": "string" + } + }, + "disabled_commands": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "init-deep", + "ralph-loop", + "ulw-loop", + "cancel-ralph", + "refactor", + "start-work", + "stop-continuation", + "remove-ai-slops" + ] + } + }, + "disabled_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "mcp_env_allowlist": { + "type": "array", + "items": { + "type": "string" + } + }, + "hashline_edit": { + "type": "boolean" + }, + "model_fallback": { + "type": "boolean" + }, + "agents": { + "type": "object", + "properties": { + "build": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "plan": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "sisyphus": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "hephaestus": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "allow_non_gpt_model": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "sisyphus-junior": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "OpenCode-Builder": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "prometheus": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "metis": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "momus": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "oracle": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "librarian": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "explore": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "multimodal-looker": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "atlas": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "categories": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "model" + ], + "additionalProperties": false + } + ] + } + } + ] + }, + "variant": { + "type": "string" + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "prompt_append": { + "type": "string" + }, + "max_prompt_tokens": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "is_unstable_agent": { + "type": "boolean" + }, + "disable": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "claude_code": { + "type": "object", + "properties": { + "mcp": { + "type": "boolean" + }, + "commands": { + "type": "boolean" + }, + "skills": { + "type": "boolean" + }, + "agents": { + "type": "boolean" + }, + "hooks": { + "type": "boolean" + }, + "plugins": { + "type": "boolean" + }, + "plugins_override": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + } + }, + "additionalProperties": false + }, + "sisyphus_agent": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "default_builder_enabled": { + "type": "boolean" + }, + "planner_enabled": { + "type": "boolean" + }, + "replace_plan": { + "type": "boolean" + }, + "tdd": { + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "comment_checker": { + "type": "object", + "properties": { + "custom_prompt": { + "type": "string" + } + }, + "additionalProperties": false + }, + "experimental": { + "type": "object", + "properties": { + "aggressive_truncation": { + "type": "boolean" + }, + "auto_resume": { + "type": "boolean" + }, + "preemptive_compaction": { + "type": "boolean" + }, + "truncate_all_tool_outputs": { + "type": "boolean" + }, + "dynamic_context_pruning": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "notification": { + "default": "detailed", + "type": "string", + "enum": [ + "off", + "minimal", + "detailed" + ] + }, + "turn_protection": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "turns": { + "default": 3, + "type": "number", + "minimum": 1, + "maximum": 10 + } + }, + "required": [ + "enabled", + "turns" + ], + "additionalProperties": false + }, + "protected_tools": { + "default": [ + "task", + "todowrite", + "todoread", + "lsp_rename", + "session_read", + "session_write", + "session_search" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "strategies": { + "type": "object", + "properties": { + "deduplication": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "supersede_writes": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "aggressive": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "enabled", + "aggressive" + ], + "additionalProperties": false + }, + "purge_errors": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "turns": { + "default": 5, + "type": "number", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "enabled", + "turns" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "enabled", + "notification", + "protected_tools" + ], + "additionalProperties": false + }, + "task_system": { + "type": "boolean" + }, + "plugin_load_timeout_ms": { + "type": "number", + "minimum": 1000 + }, + "safe_hook_creation": { + "type": "boolean" + }, + "disable_omo_env": { + "type": "boolean" + }, + "hashline_edit": { + "type": "boolean" + }, + "model_fallback_title": { + "type": "boolean" + }, + "max_tools": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, + "auto_update": { + "type": "boolean" + }, + "skills": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "glob": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + ] + } + }, + "enable": { + "type": "array", + "items": { + "type": "string" + } + }, + "disable": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "template": { + "type": "string" + }, + "from": { + "type": "string" + }, + "model": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "subtask": { + "type": "boolean" + }, + "argument-hint": { + "type": "string" + }, + "license": { + "type": "string" + }, + "compatibility": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "allowed-tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "disable": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "ralph_loop": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "default_max_iterations": { + "default": 100, + "type": "number", + "minimum": 1, + "maximum": 1000 + }, + "state_dir": { + "type": "string" + }, + "default_strategy": { + "default": "continue", + "type": "string", + "enum": [ + "reset", + "continue" + ] + } + }, + "required": [ + "enabled", + "default_max_iterations", + "default_strategy" + ], + "additionalProperties": false + }, + "runtime_fallback": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "retry_on_errors": { + "type": "array", + "items": { + "type": "number" + } + }, + "max_fallback_attempts": { + "type": "number", + "minimum": 1, + "maximum": 20 + }, + "cooldown_seconds": { + "type": "number", + "minimum": 0 + }, + "timeout_seconds": { + "type": "number", + "minimum": 0 + }, + "notify_on_fallback": { + "type": "boolean" + } + }, + "additionalProperties": false + } + ] + }, + "background_task": { + "type": "object", + "properties": { + "defaultConcurrency": { + "type": "number", + "minimum": 1 + }, + "providerConcurrency": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number", + "minimum": 0 + } + }, + "modelConcurrency": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number", + "minimum": 0 + } + }, + "maxDepth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxDescendants": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "staleTimeoutMs": { + "type": "number", + "minimum": 60000 + }, + "messageStalenessTimeoutMs": { + "type": "number", + "minimum": 60000 + }, + "taskTtlMs": { + "type": "number", + "minimum": 300000 + }, + "sessionGoneTimeoutMs": { + "type": "number", + "minimum": 10000 + }, + "syncPollTimeoutMs": { + "type": "number", + "minimum": 60000 + }, + "maxToolCalls": { + "type": "integer", + "minimum": 10, + "maximum": 9007199254740991 + }, + "circuitBreaker": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxToolCalls": { + "type": "integer", + "minimum": 10, + "maximum": 9007199254740991 + }, + "consecutiveThreshold": { + "type": "integer", + "minimum": 5, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "notification": { + "type": "object", + "properties": { + "force_enable": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "model_capabilities": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "auto_refresh_on_start": { + "type": "boolean" + }, + "refresh_timeout_ms": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "source_url": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false + }, + "openclaw": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "gateways": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "default": "http", + "type": "string", + "enum": [ + "http", + "command" + ] + }, + "url": { + "type": "string" + }, + "method": { + "default": "POST", + "type": "string" + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "command": { + "type": "string" + }, + "timeout": { + "type": "number" + } + }, + "required": [ + "type", + "method" + ], + "additionalProperties": false + } + }, + "hooks": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "default": true, + "type": "boolean" + }, + "gateway": { + "type": "string" + }, + "instruction": { + "type": "string" + } + }, + "required": [ + "enabled", + "gateway", + "instruction" + ], + "additionalProperties": false + } + }, + "replyListener": { + "type": "object", + "properties": { + "discordBotToken": { + "type": "string" + }, + "discordChannelId": { + "type": "string" + }, + "discordMention": { + "type": "string" + }, + "authorizedDiscordUserIds": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "telegramBotToken": { + "type": "string" + }, + "telegramChatId": { + "type": "string" + }, + "pollIntervalMs": { + "default": 3000, + "type": "number" + }, + "rateLimitPerMinute": { + "default": 10, + "type": "number" + }, + "maxMessageLength": { + "default": 500, + "type": "number" + }, + "includePrefix": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "authorizedDiscordUserIds", + "pollIntervalMs", + "rateLimitPerMinute", + "maxMessageLength", + "includePrefix" + ], + "additionalProperties": false + } + }, + "required": [ + "enabled", + "gateways", + "hooks" + ], + "additionalProperties": false + }, + "babysitting": { + "type": "object", + "properties": { + "timeout_ms": { + "default": 120000, + "type": "number" + } + }, + "required": [ + "timeout_ms" + ], + "additionalProperties": false + }, + "git_master": { + "default": { + "commit_footer": true, + "include_co_authored_by": true, + "git_env_prefix": "GIT_MASTER=1" + }, + "type": "object", + "properties": { + "commit_footer": { + "default": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "include_co_authored_by": { + "default": true, + "type": "boolean" + }, + "git_env_prefix": { + "default": "GIT_MASTER=1", + "type": "string" + } + }, + "required": [ + "commit_footer", + "include_co_authored_by", + "git_env_prefix" + ], + "additionalProperties": false + }, + "browser_automation_engine": { + "type": "object", + "properties": { + "provider": { + "default": "playwright", + "type": "string", + "enum": [ + "playwright", + "agent-browser", + "dev-browser", + "playwright-cli" + ] + } + }, + "required": [ + "provider" + ], + "additionalProperties": false + }, + "websearch": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "exa", + "tavily" + ] + } + }, + "additionalProperties": false + }, + "tmux": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "layout": { + "default": "main-vertical", + "type": "string", + "enum": [ + "main-horizontal", + "main-vertical", + "tiled", + "even-horizontal", + "even-vertical" + ] + }, + "main_pane_size": { + "default": 60, + "type": "number", + "minimum": 20, + "maximum": 80 + }, + "main_pane_min_width": { + "default": 120, + "type": "number", + "minimum": 40 + }, + "agent_pane_min_width": { + "default": 40, + "type": "number", + "minimum": 20 + }, + "isolation": { + "default": "inline", + "type": "string", + "enum": [ + "inline", + "window", + "session" + ] + } + }, + "required": [ + "enabled", + "layout", + "main_pane_size", + "main_pane_min_width", + "agent_pane_min_width", + "isolation" + ], + "additionalProperties": false + }, + "sisyphus": { + "type": "object", + "properties": { + "tasks": { + "type": "object", + "properties": { + "storage_path": { + "type": "string" + }, + "task_list_id": { + "type": "string" + }, + "claude_code_compat": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "claude_code_compat" + ], + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "start_work": { + "type": "object", + "properties": { + "auto_commit": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "auto_commit" + ], + "additionalProperties": false + }, + "_migrations": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "git_master" + ], + "additionalProperties": false } \ No newline at end of file diff --git a/script/build-schema-document.ts b/script/build-schema-document.ts index 18ee99355..2a84ef907 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,14 +1,17 @@ -import { zodToJsonSchema } from "zod-to-json-schema" +import { z } from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" export function createOhMyOpenCodeJsonSchema(): Record { - const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema) as Record + const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { + target: "draft-7", + unrepresentable: "any", + }) as Record return { - ...jsonSchema, $schema: "http://json-schema.org/draft-07/schema#", $id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", title: "Oh My OpenCode Configuration", description: "Configuration schema for oh-my-opencode plugin", + ...jsonSchema, } } From 5188df903f7f0e4a28e775b196126b10c678d2e4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:00:17 +0900 Subject: [PATCH 06/86] fix(types): revert task-tool type inference workarounds Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/tools/task/task-list.ts | 5 ++--- src/tools/task/task-update.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tools/task/task-list.ts b/src/tools/task/task-list.ts index 3bdce05dd..480015b59 100644 --- a/src/tools/task/task-list.ts +++ b/src/tools/task/task-list.ts @@ -37,8 +37,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript return JSON.stringify({ tasks: [] }) } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const allTasks: any[] = [] + const allTasks: TaskObject[] = [] for (const fileId of files) { const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema) if (task) { @@ -56,7 +55,7 @@ Returns summary format: id, subject, status, owner, blockedBy (not full descript // Build summary with filtered blockedBy const summaries: TaskSummary[] = activeTasks.map((task) => { // Filter blockedBy to only include unresolved (non-completed) blockers - const unresolvedBlockers = (task.blockedBy ?? []).filter((blockerId: string) => { + const unresolvedBlockers = task.blockedBy.filter((blockerId: string) => { const blockerTask = taskMap.get(blockerId) // Include if blocker doesn't exist (missing) or if it's not completed return !blockerTask || blockerTask.status !== "completed" diff --git a/src/tools/task/task-update.ts b/src/tools/task/task-update.ts index 7b3191b5f..b56bd9add 100644 --- a/src/tools/task/task-update.ts +++ b/src/tools/task/task-update.ts @@ -114,12 +114,12 @@ async function handleUpdate( const addBlocks = args.addBlocks as string[] | undefined; if (addBlocks) { - task.blocks = [...new Set([...(task.blocks ?? []), ...addBlocks])]; + task.blocks = [...new Set([...task.blocks, ...addBlocks])]; } const addBlockedBy = args.addBlockedBy as string[] | undefined; if (addBlockedBy) { - task.blockedBy = [...new Set([...(task.blockedBy ?? []), ...addBlockedBy])]; + task.blockedBy = [...new Set([...task.blockedBy, ...addBlockedBy])]; } if (validatedArgs.metadata !== undefined) { From 130e67a4324c6ee4581d8256f349943c36c0797c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:01:41 +0900 Subject: [PATCH 07/86] fix(zwsp): strip zero-width chars in boulder-continuation-injector Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/atlas/boulder-continuation-injector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index ad3a3cf27..ca4ee146e 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -55,7 +55,7 @@ export async function injectBoulderContinuation(input: { `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + preferredSessionContext + worktreeContext - const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined) + const continuationAgent = (agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined))?.replace(/\u200B/g, "") if (!continuationAgent || !isAgentRegistered(continuationAgent)) { log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, { From 3724093618bd8b4eaed8fc9fd68331c2b76bb74a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:01:43 +0900 Subject: [PATCH 08/86] fix(zwsp): strip zero-width chars from agent headers in command-config-handler Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin-handlers/command-config-handler.test.ts | 4 ++-- src/plugin-handlers/command-config-handler.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index b5837c76b..19f31f1e3 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,7 +5,7 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; -import { getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -119,6 +119,6 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 08b40d4d1..f45ff6531 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,5 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; import { loadUserCommands, loadProjectCommands, @@ -96,7 +96,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentListDisplayName(cmd.agent); + cmd.agent = getAgentDisplayName(cmd.agent); } } } From 317e2c6465effc8bd8309b9d8354d85671efb960 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:01:51 +0900 Subject: [PATCH 09/86] fix(zwsp): strip zero-width chars in start-work-hook Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/start-work/start-work-hook.ts | 3 ++- src/shared/agent-display-names.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index f916c2eae..7a85491df 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -14,6 +14,7 @@ import { log } from "../../shared/logger" import { getAgentDisplayName, getAgentListDisplayName, + stripAgentListSortPrefix, } from "../../shared/agent-display-names" import { isAgentRegistered, @@ -90,7 +91,7 @@ export function createStartWorkHook(ctx: PluginInput) { : getAgentDisplayName(activeAgent) updateSessionAgent(input.sessionID, activeAgent) if (output.message) { - output.message["agent"] = activeAgentDisplayName + output.message["agent"] = stripAgentListSortPrefix(activeAgentDisplayName) } const existingState = readBoulderState(ctx.directory) diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index d74287c28..9841074e4 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -33,7 +33,7 @@ const AGENT_LIST_SORT_PREFIXES: Record = { atlas: "\u200B\u200B\u200B\u200B", } -function stripAgentListSortPrefix(agentName: string): string { +export function stripAgentListSortPrefix(agentName: string): string { return agentName.replace(/^\u200B+/, "") } From e8d83b5f983031d1cfc3aa875aad6d530ef17942 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:02:05 +0900 Subject: [PATCH 10/86] fix(zwsp): strip zero-width chars in delegate-task tools Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/tools/delegate-task/subagent-resolver.ts | 5 +++-- src/tools/delegate-task/sync-prompt-sender.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index f5a255c70..8ba7cdd8e 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -89,9 +89,10 @@ Create the work plan directly - that's your job as the planning agent.`, const callableAgents = agents.filter((agent) => isTaskCallableAgentMode(agent.mode)) - const resolvedDisplayName = getAgentDisplayName(agentToUse) + const resolvedDisplayName = getAgentDisplayName(agentToUse).replace(/^\u200B+/, "") + const normalizedAgentToUse = agentToUse.replace(/^\u200B+/, "") const matchedAgent = callableAgents.find( - (agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() + (agent) => agent.name.toLowerCase() === normalizedAgentToUse.toLowerCase() || agent.name.toLowerCase() === resolvedDisplayName.toLowerCase() ) if (!matchedAgent) { diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index 489804253..882258d98 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -80,7 +80,7 @@ export async function sendSyncPrompt( const promptArgs = { path: { id: input.sessionID }, body: { - agent: input.agentToUse, + agent: input.agentToUse.replace(/^\u200B+/, ""), system: input.systemContent, tools, parts: [createInternalAgentTextPart(effectivePrompt)], From e52dd340c65e4b48a28ec4796bb5fdfd88829365 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:02:51 +0900 Subject: [PATCH 11/86] fix(plugin): migrate chat.params to maxOutputTokens for v1.4.0 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/chat-params.test.ts | 17 +++++++---------- src/plugin/chat-params.ts | 10 +++++++--- src/shared/session-prompt-params-state.ts | 3 +++ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/plugin/chat-params.test.ts b/src/plugin/chat-params.test.ts index 5f17f36eb..f75c1a243 100644 --- a/src/plugin/chat-params.test.ts +++ b/src/plugin/chat-params.test.ts @@ -123,10 +123,10 @@ describe("createChatParamsHandler", () => { setSessionPromptParams("ses_chat_params_temperature", { temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) @@ -157,31 +157,29 @@ describe("createChatParamsHandler", () => { temperature: 0.4, topP: 0.7, topK: 1, + maxOutputTokens: 4096, options: { existing: true, reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) expect(getSessionPromptParams("ses_chat_params_temperature")).toEqual({ temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) }) - test("drops gpt-5.4 temperature and clamps maxTokens from bundled model capabilities", async () => { + test("drops gpt-5.4 temperature and clamps maxOutputTokens from bundled model capabilities", async () => { //#given setSessionPromptParams("ses_chat_params_temperature", { temperature: 0.7, - options: { - maxTokens: 200_000, - }, + maxOutputTokens: 200_000, }) const handler = createChatParamsHandler({ @@ -210,9 +208,8 @@ describe("createChatParamsHandler", () => { expect(output).toEqual({ topP: 1, topK: 1, - options: { - maxTokens: 128_000, - }, + maxOutputTokens: 128_000, + options: {}, }) }) diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index d69a14f8e..3bc992dae 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -18,6 +18,7 @@ export type ChatParamsOutput = { temperature?: number topP?: number topK?: number + maxOutputTokens?: number options: Record } @@ -99,6 +100,9 @@ export function createChatParamsHandler(args: { if (storedPromptParams.topP !== undefined) { output.topP = storedPromptParams.topP } + if (storedPromptParams.maxOutputTokens !== undefined) { + output.maxOutputTokens = storedPromptParams.maxOutputTokens + } if (storedPromptParams.options) { output.options = { ...output.options, @@ -124,7 +128,7 @@ export function createChatParamsHandler(args: { : undefined, temperature: typeof output.temperature === "number" ? output.temperature : undefined, topP: typeof output.topP === "number" ? output.topP : undefined, - maxTokens: typeof output.options.maxTokens === "number" ? output.options.maxTokens : undefined, + maxTokens: typeof output.maxOutputTokens === "number" ? output.maxOutputTokens : undefined, thinking: isRecord(output.options.thinking) ? output.options.thinking : undefined, }, capabilities, @@ -163,9 +167,9 @@ export function createChatParamsHandler(args: { if ("maxTokens" in compatibility) { if (compatibility.maxTokens !== undefined) { - output.options.maxTokens = compatibility.maxTokens + output.maxOutputTokens = compatibility.maxTokens } else { - delete output.options.maxTokens + delete output.maxOutputTokens } } diff --git a/src/shared/session-prompt-params-state.ts b/src/shared/session-prompt-params-state.ts index 36e956cfc..1df7d526f 100644 --- a/src/shared/session-prompt-params-state.ts +++ b/src/shared/session-prompt-params-state.ts @@ -1,6 +1,7 @@ export type SessionPromptParams = { temperature?: number topP?: number + maxOutputTokens?: number options?: Record } @@ -10,6 +11,7 @@ export function setSessionPromptParams(sessionID: string, params: SessionPromptP sessionPromptParams.set(sessionID, { ...(params.temperature !== undefined ? { temperature: params.temperature } : {}), ...(params.topP !== undefined ? { topP: params.topP } : {}), + ...(params.maxOutputTokens !== undefined ? { maxOutputTokens: params.maxOutputTokens } : {}), ...(params.options !== undefined ? { options: { ...params.options } } : {}), }) } @@ -21,6 +23,7 @@ export function getSessionPromptParams(sessionID: string): SessionPromptParams | return { ...(params.temperature !== undefined ? { temperature: params.temperature } : {}), ...(params.topP !== undefined ? { topP: params.topP } : {}), + ...(params.maxOutputTokens !== undefined ? { maxOutputTokens: params.maxOutputTokens } : {}), ...(params.options !== undefined ? { options: { ...params.options } } : {}), } } From 78e6d780eb53f3512fbfffa223e64c639d79ccc1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:08:03 +0900 Subject: [PATCH 12/86] fix(plugin): verify event hook compatibility with v1.4.0 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../compaction-aware-message-resolver.test.ts | 52 ++++++++++++++- .../compaction-aware-message-resolver.ts | 23 +++++-- .../hook-message-injector/injector.test.ts | 59 +++++++++++++++++ .../hook-message-injector/injector.ts | 38 ++++++++++- .../atlas/session-last-agent.json.test.ts | 33 ++++++++++ .../atlas/session-last-agent.sqlite.test.ts | 24 +++++++ src/hooks/atlas/session-last-agent.ts | 31 ++++++--- .../todo-continuation-enforcer/idle-event.ts | 7 ++ .../pending-question-detection.ts | 2 +- .../resolve-message-info.ts | 15 ++++- .../todo-continuation-enforcer.test.ts | 65 +++++++++++++++++-- src/hooks/todo-continuation-enforcer/types.ts | 2 + src/plugin/chat-params.ts | 2 +- src/shared/compaction-marker.ts | 57 ++++++++++++++++ src/shared/index.ts | 1 + 15 files changed, 383 insertions(+), 28 deletions(-) create mode 100644 src/shared/compaction-marker.ts diff --git a/src/features/background-agent/compaction-aware-message-resolver.test.ts b/src/features/background-agent/compaction-aware-message-resolver.test.ts index 5b9bed5af..d4fe51046 100644 --- a/src/features/background-agent/compaction-aware-message-resolver.test.ts +++ b/src/features/background-agent/compaction-aware-message-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" -import { mkdtempSync, writeFileSync, rmSync } from "node:fs" +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { @@ -11,6 +11,7 @@ import { clearCompactionAgentConfigCheckpoint, setCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" +import { PART_STORAGE } from "../../shared" describe("isCompactionAgent", () => { describe("#given agent name variations", () => { @@ -73,6 +74,7 @@ describe("findNearestMessageExcludingCompaction", () => { afterEach(() => { rmSync(tempDir, { force: true, recursive: true }) + rmSync(join(PART_STORAGE, "msg_test_background_compaction_marker"), { force: true, recursive: true }) clearCompactionAgentConfigCheckpoint("ses_checkpoint") }) @@ -116,6 +118,30 @@ describe("findNearestMessageExcludingCompaction", () => { expect(result?.agent).toBe("sisyphus") }) + test("skips JSON messages whose part storage contains a compaction marker", () => { + // given + const compactionMessageID = "msg_test_background_compaction_marker" + const partDir = join(PART_STORAGE, compactionMessageID) + writeFileSync(join(tempDir, "002.json"), JSON.stringify({ + id: compactionMessageID, + agent: "atlas", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + })) + writeFileSync(join(tempDir, "001.json"), JSON.stringify({ + id: "msg_001", + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + })) + mkdirSync(partDir, { recursive: true }) + writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" })) + + // when + const result = findNearestMessageExcludingCompaction(tempDir) + + // then + expect(result?.agent).toBe("sisyphus") + }) + test("falls back to partial agent/model match", () => { // given const messageWithAgentOnly = { @@ -256,4 +282,28 @@ describe("resolvePromptContextFromSessionMessages", () => { tools: { bash: true }, }) }) + + test("skips SDK messages that only exist to mark compaction", () => { + // given + const messages = [ + { + id: "msg_compaction", + info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" } }, + parts: [{ type: "compaction" }], + }, + { info: { agent: "sisyphus" } }, + { info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } }, + { info: { tools: { bash: true } } }, + ] + + // when + const result = resolvePromptContextFromSessionMessages(messages) + + // then + expect(result).toEqual({ + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4-1" }, + tools: { bash: true }, + }) + }) }) diff --git a/src/features/background-agent/compaction-aware-message-resolver.ts b/src/features/background-agent/compaction-aware-message-resolver.ts index 60b3949b3..573002b4f 100644 --- a/src/features/background-agent/compaction-aware-message-resolver.ts +++ b/src/features/background-agent/compaction-aware-message-resolver.ts @@ -2,8 +2,16 @@ import { readdirSync, readFileSync } from "node:fs" import { join } from "node:path" import type { StoredMessage } from "../hook-message-injector" import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint" +import { + hasCompactionPartInStorage, + isCompactionAgent, + isCompactionMessage, +} from "../../shared/compaction-marker" + +export { isCompactionAgent } from "../../shared/compaction-marker" type SessionMessage = { + id?: string info?: { agent?: string model?: { @@ -15,10 +23,7 @@ type SessionMessage = { modelID?: string tools?: StoredMessage["tools"] } -} - -export function isCompactionAgent(agent: string | undefined): boolean { - return agent?.trim().toLowerCase() === "compaction" + parts?: Array<{ type?: string }> } function hasFullAgentAndModel(message: StoredMessage): boolean { @@ -35,6 +40,10 @@ function hasPartialAgentOrModel(message: StoredMessage): boolean { } function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null { + if (isCompactionMessage(message)) { + return null + } + const info = message.info if (!info) { return null @@ -138,7 +147,11 @@ export function findNearestMessageExcludingCompaction( for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") - messages.push(JSON.parse(content) as StoredMessage) + const parsed = JSON.parse(content) as StoredMessage & { id?: string } + if (hasCompactionPartInStorage(parsed.id) || isCompactionAgent(parsed.agent)) { + continue + } + messages.push(parsed) } catch { continue } diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index 663b5e068..3db367640 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -11,6 +11,7 @@ import { generatePartId, injectHookMessage, } from "./injector" +import { PART_STORAGE } from "../../shared" import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection" //#region Mocks @@ -53,6 +54,7 @@ function createMockClient(messages: Array<{ tools?: Record time?: { created?: number } } + parts?: Array<{ type?: string }> }>): { session: { messages: (opts: { path: { id: string } }) => Promise<{ data: typeof messages }> @@ -176,6 +178,24 @@ describe("findNearestMessageWithFieldsFromSDK", () => { expect(result?.agent).toBe("newest-by-time") }) + + it("skips compaction marker user messages when resolving nearest message", async () => { + const mockClient = createMockClient([ + { + id: "msg_compaction", + info: { agent: "atlas", model: { providerID: "openai", modelID: "gpt-5" }, time: { created: 200 } }, + parts: [{ type: "compaction" }], + }, + { + id: "msg_real", + info: { agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-opus-4" }, time: { created: 100 } }, + }, + ]) + + const result = await findNearestMessageWithFieldsFromSDK(mockClient as any, "ses_123") + + expect(result?.agent).toBe("sisyphus") + }) }) describe("findNearestMessageWithFields JSON backend ordering", () => { @@ -197,6 +217,34 @@ describe("findNearestMessageWithFields JSON backend ordering", () => { expect(result?.agent).toBe("newest-by-time") }) + + it("skips JSON messages whose parts contain a compaction marker", () => { + mockIsSqliteBackend.mockReturnValue(false) + const messageDir = createMessageDir() + const compactionMessageID = "msg_test_injector_compaction_marker" + const partDir = join(PART_STORAGE, compactionMessageID) + tempDirs.push(partDir) + + writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({ + id: compactionMessageID, + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5" }, + time: { created: 200 }, + })) + mkdirSync(partDir, { recursive: true }) + writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" })) + + writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({ + id: "msg_0002", + agent: "sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4" }, + time: { created: 100 }, + })) + + const result = findNearestMessageWithFields(messageDir) + + expect(result?.agent).toBe("sisyphus") + }) }) describe("findFirstMessageWithAgentFromSDK", () => { @@ -222,6 +270,17 @@ describe("findFirstMessageWithAgentFromSDK", () => { expect(result).toBe("earliest-agent") }) + it("skips compaction marker user messages when resolving first agent", async () => { + const mockClient = createMockClient([ + { id: "msg_compaction", info: { agent: "atlas", time: { created: 10 } }, parts: [{ type: "compaction" }] }, + { id: "msg_real", info: { agent: "sisyphus", time: { created: 20 } } }, + ]) + + const result = await findFirstMessageWithAgentFromSDK(mockClient as any, "ses_123") + + expect(result).toBe("sisyphus") + }) + it("skips messages without agent field", async () => { const mockClient = createMockClient([ { info: {} }, diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index a0568371e..84ecddf0e 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -7,6 +7,7 @@ import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } fr import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" +import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker" export interface StoredMessage { agent?: string @@ -32,6 +33,7 @@ interface SDKMessage { created?: number } } + parts?: Array<{ type?: string }> } const processPrefix = randomBytes(4).toString("hex") @@ -39,6 +41,10 @@ let messageCounter = 0 let partCounter = 0 function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { + if (isCompactionMessage(msg)) { + return null + } + const info = msg.info if (!info) return null @@ -164,22 +170,38 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage return { fileName, msg, + hasCompactionMarker: hasCompactionPartInStorage( + typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined, + ), createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.NEGATIVE_INFINITY, } } catch { return null } }) - .filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null) + .filter((entry): entry is { + fileName: string + msg: StoredMessage & { time?: { created?: number } } + hasCompactionMarker: boolean + createdAt: number + } => entry !== null) .sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName)) for (const entry of messages) { + if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) { + continue + } + if (entry.msg.agent && entry.msg.model?.providerID && entry.msg.model?.modelID) { return entry.msg } } for (const entry of messages) { + if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) { + continue + } + if (entry.msg.agent || (entry.msg.model?.providerID && entry.msg.model?.modelID)) { return entry.msg } @@ -216,16 +238,28 @@ export function findFirstMessageWithAgent(messageDir: string): string | null { return { fileName, msg, + hasCompactionMarker: hasCompactionPartInStorage( + typeof (msg as { id?: unknown }).id === "string" ? (msg as { id?: string }).id : undefined, + ), createdAt: typeof msg.time?.created === "number" ? msg.time.created : Number.POSITIVE_INFINITY, } } catch { return null } }) - .filter((entry): entry is { fileName: string; msg: StoredMessage & { time?: { created?: number } }; createdAt: number } => entry !== null) + .filter((entry): entry is { + fileName: string + msg: StoredMessage & { time?: { created?: number } } + hasCompactionMarker: boolean + createdAt: number + } => entry !== null) .sort((left, right) => left.createdAt - right.createdAt || left.fileName.localeCompare(right.fileName)) for (const entry of messages) { + if (entry.hasCompactionMarker || isCompactionMessage({ agent: entry.msg.agent })) { + continue + } + if (entry.msg.agent) { return entry.msg.agent } diff --git a/src/hooks/atlas/session-last-agent.json.test.ts b/src/hooks/atlas/session-last-agent.json.test.ts index 196078a50..fec271338 100644 --- a/src/hooks/atlas/session-last-agent.json.test.ts +++ b/src/hooks/atlas/session-last-agent.json.test.ts @@ -3,6 +3,7 @@ const { afterEach, describe, expect, mock, test, afterAll } = require("bun:test" import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" +import { PART_STORAGE } from "../../shared" const testDirs: string[] = [] const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-session-last-agent-${Date.now()}`) @@ -64,4 +65,36 @@ describe("getLastAgentFromSession JSON backend", () => { // then expect(result).toBe("atlas") }) + + test("skips JSON messages whose part storage contains a compaction marker", async () => { + // given + const sessionID = "ses_json_compaction_marker" + const messageDir = createTempMessageDir(sessionID) + const compactionMessageID = "msg_test_atlas_compaction_marker" + const partDir = join(PART_STORAGE, compactionMessageID) + testDirs.push(partDir) + writeFileSync(join(messageDir, "msg_0001.json"), JSON.stringify({ + id: compactionMessageID, + agent: "atlas", + time: { created: 200 }, + }), "utf-8") + mkdirSync(partDir, { recursive: true }) + writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ + type: "compaction", + }), "utf-8") + + writeFileSync(join(messageDir, "msg_0002.json"), JSON.stringify({ + id: "msg_0002", + agent: "sisyphus-junior", + time: { created: 100 }, + }), "utf-8") + + const { getLastAgentFromSession } = await import("./session-last-agent") + + // when + const result = await getLastAgentFromSession(sessionID) + + // then + expect(result).toBe("sisyphus-junior") + }) }) diff --git a/src/hooks/atlas/session-last-agent.sqlite.test.ts b/src/hooks/atlas/session-last-agent.sqlite.test.ts index a5ce6dbcb..5ae770298 100644 --- a/src/hooks/atlas/session-last-agent.sqlite.test.ts +++ b/src/hooks/atlas/session-last-agent.sqlite.test.ts @@ -52,6 +52,30 @@ describe("getLastAgentFromSession SQLite backend ordering", () => { expect(result).toBe("sisyphus-junior") }) + test("skips compaction marker user messages that retain the original agent", async () => { + // given + const client = { + session: { + messages: async () => ({ + data: [ + { id: "msg_real", info: { agent: "sisyphus", time: { created: 100 } } }, + { + id: "msg_compaction", + info: { agent: "atlas", time: { created: 200 } }, + parts: [{ type: "compaction" }], + }, + ], + }), + }, + } + + // when + const result = await getLastAgentFromSession("ses_sqlite_compaction_marker", client as never) + + // then + expect(result).toBe("sisyphus") + }) + test("returns null instead of throwing when SQLite message lookup fails", async () => { // given const client = { diff --git a/src/hooks/atlas/session-last-agent.ts b/src/hooks/atlas/session-last-agent.ts index 43933b33f..4f12fb022 100644 --- a/src/hooks/atlas/session-last-agent.ts +++ b/src/hooks/atlas/session-last-agent.ts @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs" import { join } from "node:path" import { getMessageDir, isSqliteBackend, normalizeSDKResponse } from "../../shared" +import { hasCompactionPartInStorage, isCompactionMessage } from "../../shared/compaction-marker" type SessionMessagesClient = { session: { @@ -9,10 +10,6 @@ type SessionMessagesClient = { } } -function isCompactionAgent(agent: unknown): boolean { - return typeof agent === "string" && agent.toLowerCase() === "compaction" -} - function getLastAgentFromMessageDir(messageDir: string): string | null { try { const messages = readdirSync(messageDir) @@ -20,9 +17,10 @@ function getLastAgentFromMessageDir(messageDir: string): string | null { .map((fileName) => { try { const content = readFileSync(join(messageDir, fileName), "utf-8") - const parsed = JSON.parse(content) as { agent?: unknown; time?: { created?: unknown } } + const parsed = JSON.parse(content) as { id?: string; agent?: unknown; time?: { created?: unknown } } return { fileName, + id: parsed.id, agent: parsed.agent, createdAt: typeof parsed.time?.created === "number" ? parsed.time.created : Number.NEGATIVE_INFINITY, } @@ -30,11 +28,16 @@ function getLastAgentFromMessageDir(messageDir: string): string | null { return null } }) - .filter((message): message is { fileName: string; agent: unknown; createdAt: number } => message !== null) - .sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName)) + .filter((message): message is { fileName: string; id: string | undefined; agent: unknown; createdAt: number } => message !== null) + .sort((left, right) => (right?.createdAt ?? 0) - (left?.createdAt ?? 0) || (right?.fileName ?? "").localeCompare(left?.fileName ?? "")) for (const message of messages) { - if (typeof message.agent === "string" && !isCompactionAgent(message.agent)) { + if (!message) continue + if (isCompactionMessage({ agent: message.agent }) || hasCompactionPartInStorage(message?.id)) { + continue + } + + if (typeof message.agent === "string") { return message.agent.toLowerCase() } } @@ -52,7 +55,11 @@ export async function getLastAgentFromSession( if (isSqliteBackend() && client) { try { const response = await client.session.messages({ path: { id: sessionID } }) - const messages = normalizeSDKResponse(response, [] as Array<{ id?: string; info?: { agent?: string; time?: { created?: number } } }>, { + const messages = normalizeSDKResponse(response, [] as Array<{ + id?: string + info?: { agent?: string; time?: { created?: number } } + parts?: Array<{ type?: string }> + }>, { preferResponseOnMissingData: true, }).sort((left, right) => { const leftTime = (left as { info?: { time?: { created?: number } } }).info?.time?.created ?? Number.NEGATIVE_INFINITY @@ -67,8 +74,12 @@ export async function getLastAgentFromSession( }) for (const message of messages) { + if (isCompactionMessage(message)) { + continue + } + const agent = message.info?.agent - if (typeof agent === "string" && !isCompactionAgent(agent)) { + if (typeof agent === "string") { return agent.toLowerCase() } } diff --git a/src/hooks/todo-continuation-enforcer/idle-event.ts b/src/hooks/todo-continuation-enforcer/idle-event.ts index 87c674105..162b60f6d 100644 --- a/src/hooks/todo-continuation-enforcer/idle-event.ts +++ b/src/hooks/todo-continuation-enforcer/idle-event.ts @@ -150,14 +150,21 @@ export async function handleSessionIdle(args: { let resolvedInfo: ResolvedMessageInfo | undefined let encounteredCompaction = false + let latestMessageWasCompaction = false try { const messageInfoResult = await resolveLatestMessageInfo(ctx, sessionID, prefetchedMessages) resolvedInfo = messageInfoResult.resolvedInfo encounteredCompaction = messageInfoResult.encounteredCompaction + latestMessageWasCompaction = messageInfoResult.latestMessageWasCompaction } catch (error) { log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(error) }) } + if (latestMessageWasCompaction) { + log(`[${HOOK_NAME}] Skipped: latest message is a compaction marker`, { sessionID }) + return + } + const sessionAgent = getSessionAgent(sessionID) if (!resolvedInfo?.agent && sessionAgent) { resolvedInfo = { ...resolvedInfo, agent: sessionAgent } diff --git a/src/hooks/todo-continuation-enforcer/pending-question-detection.ts b/src/hooks/todo-continuation-enforcer/pending-question-detection.ts index fd97b6c35..7777da03b 100644 --- a/src/hooks/todo-continuation-enforcer/pending-question-detection.ts +++ b/src/hooks/todo-continuation-enforcer/pending-question-detection.ts @@ -2,7 +2,7 @@ import { log } from "../../shared/logger" import { HOOK_NAME } from "./constants" interface MessagePart { - type: string + type?: string name?: string toolName?: string } diff --git a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts index bffd8cfd6..42431aa07 100644 --- a/src/hooks/todo-continuation-enforcer/resolve-message-info.ts +++ b/src/hooks/todo-continuation-enforcer/resolve-message-info.ts @@ -1,6 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { normalizeSDKResponse } from "../../shared" +import { isCompactionMessage } from "../../shared/compaction-marker" import type { MessageInfo, MessageWithInfo, ResolveLatestMessageInfoResult } from "./types" @@ -16,10 +17,17 @@ export async function resolveLatestMessageInfo( [] as MessageWithInfo[], ) let encounteredCompaction = false + let latestMessageWasCompaction = false for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent === "compaction") { + const message = messages[i] + const info = message.info + const isCompaction = isCompactionMessage(message) + if (i === messages.length - 1) { + latestMessageWasCompaction = isCompaction + } + + if (isCompaction) { encounteredCompaction = true continue } @@ -31,9 +39,10 @@ export async function resolveLatestMessageInfo( tools: info.tools, }, encounteredCompaction, + latestMessageWasCompaction, } } } - return { resolvedInfo: undefined, encounteredCompaction } + return { resolvedInfo: undefined, encounteredCompaction, latestMessageWasCompaction } } diff --git a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts index ecd95c885..9c5a35f5c 100644 --- a/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer/todo-continuation-enforcer.test.ts @@ -1594,8 +1594,8 @@ describe("todo-continuation-enforcer", () => { // when resolving agent info, preventing infinite continuation loops // ============================================================ - test("should skip compaction agent messages when resolving agent info", async () => { - // given - session where last message is from compaction agent but previous was Sisyphus + test("should skip injection while the latest message is from the compaction agent", async () => { + // given - session where the latest activity is still the compaction assistant turn const sessionID = "main-compaction-filter" setMainSession(sessionID) @@ -1644,9 +1644,8 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await fakeTimers.advanceBy(2500) - // then - continuation uses Sisyphus (skipped compaction agent) - expect(promptCalls.length).toBe(1) - expect(promptCalls[0].agent).toBe("sisyphus") + // then - no continuation while compaction is still the latest event + expect(promptCalls).toHaveLength(0) }) test("should skip injection when only compaction agent messages exist", async () => { @@ -1702,6 +1701,62 @@ describe("todo-continuation-enforcer", () => { expect(promptCalls).toHaveLength(0) }) + test("should skip compaction marker user messages when resolving agent info", async () => { + // given - latest user message is the OpenCode compaction marker, not a real turn + const sessionID = "main-compaction-marker-filter" + setMainSession(sessionID) + + const mockMessagesWithCompactionMarker = [ + { info: { id: "msg-1", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } }, + { + info: { id: "msg-2", role: "user", agent: "atlas", model: { providerID: "openai", modelID: "gpt-5.4" } }, + parts: [{ type: "compaction" }], + }, + ] + + const mockInput = { + client: { + session: { + todo: async () => ({ + data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], + }), + messages: async () => ({ data: mockMessagesWithCompactionMarker }), + prompt: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + promptAsync: async (opts: any) => { + promptCalls.push({ + sessionID: opts.path.id, + agent: opts.body.agent, + model: opts.body.model, + text: opts.body.parts[0].text, + }) + return {} + }, + }, + tui: { showToast: async () => ({}) }, + }, + directory: "/tmp/test", + } as any + + const hook = createTodoContinuationEnforcer(mockInput, { + backgroundManager: createMockBackgroundManager(false), + }) + + // when - session goes idle + await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) + await fakeTimers.advanceBy(3000) + + // then - no continuation while the compaction marker is the latest event + expect(promptCalls).toHaveLength(0) + }) + test("should skip injection when prometheus agent is after compaction", async () => { // given - prometheus session that was compacted const sessionID = "main-prometheus-compacted" diff --git a/src/hooks/todo-continuation-enforcer/types.ts b/src/hooks/todo-continuation-enforcer/types.ts index d28874ed8..3d0e61770 100644 --- a/src/hooks/todo-continuation-enforcer/types.ts +++ b/src/hooks/todo-continuation-enforcer/types.ts @@ -54,6 +54,7 @@ export interface MessageInfo { export interface MessageWithInfo { info?: MessageInfo + parts?: Array<{ type?: string }> } export interface ResolvedMessageInfo { @@ -65,6 +66,7 @@ export interface ResolvedMessageInfo { export interface ResolveLatestMessageInfoResult { resolvedInfo?: ResolvedMessageInfo encounteredCompaction: boolean + latestMessageWasCompaction: boolean } export interface ContinuationProgressOptions { diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index 3bc992dae..b28f6a420 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -101,7 +101,7 @@ export function createChatParamsHandler(args: { output.topP = storedPromptParams.topP } if (storedPromptParams.maxOutputTokens !== undefined) { - output.maxOutputTokens = storedPromptParams.maxOutputTokens + (output as Record).maxOutputTokens = storedPromptParams.maxOutputTokens } if (storedPromptParams.options) { output.options = { diff --git a/src/shared/compaction-marker.ts b/src/shared/compaction-marker.ts new file mode 100644 index 000000000..6af43e774 --- /dev/null +++ b/src/shared/compaction-marker.ts @@ -0,0 +1,57 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" +import { PART_STORAGE } from "./opencode-storage-paths" + +type CompactionPartLike = { + type?: unknown +} + +type CompactionMessageLike = { + agent?: unknown + info?: { + agent?: unknown + } + parts?: unknown +} + +function isCompactionPart(part: unknown): boolean { + return typeof part === "object" && part !== null && (part as CompactionPartLike).type === "compaction" +} + +export function isCompactionAgent(agent: unknown): boolean { + return typeof agent === "string" && agent.trim().toLowerCase() === "compaction" +} + +export function hasCompactionPart(parts: unknown): boolean { + return Array.isArray(parts) && parts.some((part) => isCompactionPart(part)) +} + +export function isCompactionMessage(message: CompactionMessageLike): boolean { + return isCompactionAgent(message.info?.agent ?? message.agent) || hasCompactionPart(message.parts) +} + +export function hasCompactionPartInStorage(messageID: string | undefined): boolean { + if (!messageID) { + return false + } + + const partDir = join(PART_STORAGE, messageID) + if (!existsSync(partDir)) { + return false + } + + try { + return readdirSync(partDir) + .filter((fileName) => fileName.endsWith(".json")) + .some((fileName) => { + try { + const content = readFileSync(join(partDir, fileName), "utf-8") + return isCompactionPart(JSON.parse(content)) + } catch { + return false + } + }) + } catch { + return false + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index fff73f3fb..485926bfd 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -68,6 +68,7 @@ export * from "./project-discovery-dirs" export * from "./normalize-sdk-response" export * from "./session-directory-resolver" export * from "./prompt-tools" +export * from "./compaction-marker" export * from "./internal-initiator-marker" export * from "./plugin-command-discovery" export { SessionCategoryRegistry } from "./session-category-registry" From a419857b464ea5a83af9b3d785aaa874f06c3515 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:09:31 +0900 Subject: [PATCH 13/86] fix(delegate-task): use exact match for isPlanFamily to allow Metis/Momus Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/tools/delegate-task/constants.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index 510bcf80d..bff305b13 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -325,7 +325,7 @@ export const PLAN_AGENT_NAMES = ["plan"] export function isPlanAgent(agentName: string | undefined): boolean { if (!agentName) return false const lowerName = agentName.toLowerCase().trim() - return PLAN_AGENT_NAMES.some(name => lowerName === name || lowerName.includes(name)) + return PLAN_AGENT_NAMES.some(name => lowerName === name) } /** @@ -342,7 +342,5 @@ export function isPlanFamily(category: string | undefined): boolean export function isPlanFamily(category: string | undefined): boolean { if (!category) return false const lowerCategory = category.toLowerCase().trim() - return PLAN_FAMILY_NAMES.some( - (name) => lowerCategory === name || lowerCategory.includes(name) - ) + return PLAN_FAMILY_NAMES.some((name) => lowerCategory === name) } From 775140299983a380e3cf1e7a894b197c67f0dff2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:09:38 +0900 Subject: [PATCH 14/86] fix(runtime-fallback): classify quota exhaustion as STOP not retryable Remove quota exhaustion patterns from RETRYABLE_ERROR_PATTERNS: - 'usage limit reached' patterns (lines 30, 32) - 'insufficient credits' pattern (line 37) - 'credit balance too low' pattern (line 38) These errors indicate permanent quota exhaustion, not temporary rate limits. They are already handled by classifyErrorType() which returns 'quota_exceeded', and isRetryableError() properly stops on these unless there's an explicit auto-retry signal. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/runtime-fallback/constants.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index a42b10923..19a7cad56 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -27,15 +27,11 @@ export const RETRYABLE_ERROR_PATTERNS = [ /too.?many.?requests/i, /quota\s+will\s+reset\s+after/i, /quota.?exceeded/i, - /(?:you(?:'ve|\s+have)\s+)?reached\s+your\s+usage\s+limit/i, /exhausted\s+your\s+capacity/i, - /usage\s+limit\s+has\s+been\s+reached/i, /all\s+credentials\s+for\s+model/i, /cool(?:ing)?\s+down/i, /model.{0,20}?not.{0,10}?supported/i, /model_not_supported/i, - /insufficient.?(?:credits?|funds?|balance)/i, - /credit.*balance.*too.*low/i, /service.?unavailable/i, /overloaded/i, /temporarily.?unavailable/i, From b81fdef5d9179bfc179956c86aea936506331252 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:10:06 +0900 Subject: [PATCH 15/86] fix(skill-mcp): redact sensitive data from connection errors Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../skill-mcp-manager/error-redaction.ts | 47 +++++++++++++++++++ .../skill-mcp-manager/stdio-client.ts | 8 +++- 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/features/skill-mcp-manager/error-redaction.ts diff --git a/src/features/skill-mcp-manager/error-redaction.ts b/src/features/skill-mcp-manager/error-redaction.ts new file mode 100644 index 000000000..d3a3cb0df --- /dev/null +++ b/src/features/skill-mcp-manager/error-redaction.ts @@ -0,0 +1,47 @@ +// Redacts sensitive tokens from error messages to prevent credential exposure +// Follows same patterns as env-cleaner.ts for consistency + +const SENSITIVE_PATTERNS: RegExp[] = [ + // API keys and tokens in common formats + /[a-zA-Z0-9_-]*(?:api[_-]?key|apikey)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi, + /[a-zA-Z0-9_-]*(?:auth[_-]?token|authtoken)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi, + /[a-zA-Z0-9_-]*(?:access[_-]?token|accesstoken)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi, + /[a-zA-Z0-9_-]*(?:secret)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{16,})/gi, + /[a-zA-Z0-9_-]*(?:password)["\s]*[:=]["\s]*([a-zA-Z0-9_-]{8,})/gi, + + // Bearer tokens + /bearer\s+([a-zA-Z0-9_-]{20,})/gi, + + // Common token prefixes + /sk-[a-zA-Z0-9]{20,}/g, // OpenAI-style secret keys + /gh[pousr]_[a-zA-Z0-9]{20,}/gi, // GitHub tokens + /glpat-[a-zA-Z0-9_-]{20,}/gi, // GitLab tokens + /[A-Za-z0-9_]{20,}-[A-Za-z0-9_]{10,}-[A-Za-z0-9_]{10,}/g, // Common JWT-like patterns +] + +const REDACTION_MARKER = "[REDACTED]" + +/** + * Redacts sensitive tokens from a string. + * Used for error messages that may contain command-line arguments or environment info. + */ +export function redactSensitiveData(input: string): string { + let result = input + + for (const pattern of SENSITIVE_PATTERNS) { + result = result.replace(pattern, REDACTION_MARKER) + } + + return result +} + +/** + * Redacts sensitive data from an Error object, returning a new Error. + * Preserves the stack trace but redacts the message. + */ +export function redactErrorSensitiveData(error: Error): Error { + const redactedMessage = redactSensitiveData(error.message) + const redactedError = new Error(redactedMessage) + redactedError.stack = error.stack ? redactSensitiveData(error.stack) : undefined + return redactedError +} diff --git a/src/features/skill-mcp-manager/stdio-client.ts b/src/features/skill-mcp-manager/stdio-client.ts index 0d3e9047c..3a5c796a4 100644 --- a/src/features/skill-mcp-manager/stdio-client.ts +++ b/src/features/skill-mcp-manager/stdio-client.ts @@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { createCleanMcpEnvironment } from "./env-cleaner" import { registerProcessCleanup, startCleanupTimer } from "./cleanup" +import { redactSensitiveData } from "./error-redaction" import type { ManagedClient, SkillMcpClientConnectionParams } from "./types" function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string { @@ -45,10 +46,13 @@ export async function createStdioClient(params: SkillMcpClientConnectionParams): } const errorMessage = error instanceof Error ? error.message : String(error) + const fullCommand = `${command} ${args.join(" ")}` + const safeCommand = redactSensitiveData(fullCommand) + const safeErrorMessage = redactSensitiveData(errorMessage) throw new Error( `Failed to connect to MCP server "${info.serverName}".\n\n` + - `Command: ${command} ${args.join(" ")}\n` + - `Reason: ${errorMessage}\n\n` + + `Command: ${safeCommand}\n` + + `Reason: ${safeErrorMessage}\n\n` + `Hints:\n` + ` - Ensure the command is installed and available in PATH\n` + ` - Check if the MCP server package exists\n` + From 05efb20fda6e5ff339861c364327b0a155d9a713 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:10:20 +0900 Subject: [PATCH 16/86] feat(skill-mcp): add scope field to connection types --- src/features/skill-mcp-manager/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts index d2e77e3ae..3d2838d55 100644 --- a/src/features/skill-mcp-manager/types.ts +++ b/src/features/skill-mcp-manager/types.ts @@ -3,6 +3,7 @@ import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdi import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { McpOAuthProvider } from "../mcp-oauth/provider" +import type { SkillScope } from "../opencode-skill-loader/types" export type SkillMcpConfig = Record @@ -10,6 +11,7 @@ export interface SkillMcpClientInfo { serverName: string skillName: string sessionID: string + scope?: SkillScope } export interface SkillMcpServerContext { From 5e0bd87dea52c01f9fef456991ca689f56f4b989 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:10:26 +0900 Subject: [PATCH 17/86] test(runtime-fallback): add provider matrix quota tests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../runtime-fallback/provider-matrix.test.ts | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/hooks/runtime-fallback/provider-matrix.test.ts diff --git a/src/hooks/runtime-fallback/provider-matrix.test.ts b/src/hooks/runtime-fallback/provider-matrix.test.ts new file mode 100644 index 000000000..d94986e78 --- /dev/null +++ b/src/hooks/runtime-fallback/provider-matrix.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, test } from "bun:test" + +import { classifyErrorType, isRetryableError } from "./error-classifier" + +describe("runtime-fallback provider matrix quota tests", () => { + describe("OpenAI provider", () => { + test("classifies OpenAI insufficient_quota error as quota_exceeded", () => { + //#given + const error = { + name: "InsufficientQuotaError", + message: "You exceeded your current quota. Please check your plan and billing details.", + provider: "openai", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies OpenAI billing_hard_limit error as quota_exceeded", () => { + //#given + const error = { + name: "BillingError", + message: "Billing hard limit reached. You have exceeded your hard limit.", + provider: "openai", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies OpenAI rate limit as retryable", () => { + //#given + const error = { + name: "RateLimitError", + statusCode: 429, + message: "Rate limit reached for requests", + provider: "openai", + } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + }) + + describe("Anthropic provider", () => { + test("classifies Anthropic quota exceeded as non-retryable", () => { + //#given + const error = { + name: "QuotaExceededError", + message: "Your account has exceeded its quota. Please upgrade your plan.", + provider: "anthropic", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies Anthropic subscription quota as non-retryable", () => { + //#given + const error = { + name: "AI_APICallError", + message: "Subscription quota exceeded. You can continue using free models.", + provider: "anthropic", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies Anthropic cooling down with retry signal as retryable (auto-retry pattern)", () => { + //#given + const error = { + name: "AI_APICallError", + message: "All credentials for model claude-opus-4-6 are cooling down [retrying in ~2 weeks]", + provider: "anthropic", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBeUndefined() + expect(retryable).toBe(true) + }) + }) + + describe("Google/Gemini provider", () => { + test("classifies Google API key missing as missing_api_key", () => { + //#given + const error = { + name: "AI_LoadAPIKeyError", + message: + "Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.", + provider: "google", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("missing_api_key") + expect(retryable).toBe(true) + }) + + test("classifies Google quota exceeded as quota_exceeded", () => { + //#given + const error = { + name: "QuotaExceededError", + message: "Quota exceeded for quota metric 'Generate Content API requests'", + provider: "google", + } + + //#when + const errorType = classifyErrorType(error) + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(errorType).toBe("quota_exceeded") + expect(retryable).toBe(false) + }) + + test("classifies Google rate limit exceeded as retryable", () => { + //#given + const error = { + name: "ResourceExhausted", + statusCode: 429, + message: "Rate limit exceeded. Please try again later.", + provider: "google", + } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + }) + + describe("Generic provider patterns", () => { + test("classifies exhausted capacity as quota_exceeded", () => { + //#given + const error = { + message: "Sorry, you've exhausted your capacity", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies out of credits as quota_exceeded", () => { + //#given + const error = { + message: "You are out of credits. Please purchase more.", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies payment required (402) as quota_exceeded", () => { + //#given + const error = { + statusCode: 402, + message: "Payment Required", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies out of credits as quota_exceeded", () => { + //#given + const error = { + message: "You are out of credits. Please purchase more.", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies exhausted capacity as quota_exceeded", () => { + //#given + const error = { + message: "Sorry, you've exhausted your capacity", + } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + }) + + describe("Provider-specific error name patterns", () => { + test("classifies BillingError as quota_exceeded", () => { + //#given + const error = { name: "BillingError", message: "Billing issue" } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies InsufficientQuota as quota_exceeded", () => { + //#given + const error = { name: "InsufficientQuota", message: "Not enough quota" } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + + test("classifies QuotaExceeded as quota_exceeded", () => { + //#given + const error = { name: "QuotaExceeded", message: "Quota limit reached" } + + //#when + const errorType = classifyErrorType(error) + + //#then + expect(errorType).toBe("quota_exceeded") + }) + }) + + describe("HTTP status code matrix", () => { + test("429 rate limit is retryable", () => { + //#given + const error = { statusCode: 429, message: "Too many requests" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + + test("402 payment required is NOT retryable", () => { + //#given + const error = { statusCode: 402, message: "Payment Required" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(false) + }) + + test("500 server error is retryable", () => { + //#given + const error = { statusCode: 500, message: "Internal Server Error" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + + test("503 service unavailable is retryable", () => { + //#given + const error = { statusCode: 503, message: "Service Unavailable" } + + //#when + const retryable = isRetryableError(error, [429, 500, 502, 503, 504]) + + //#then + expect(retryable).toBe(true) + }) + }) +}) From fa140b0375a709ad26648b10aa8238cad726978f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:10:41 +0900 Subject: [PATCH 18/86] fix(skill-loader): propagate scope to MCP connections Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/skill-mcp-manager/connection.ts | 3 ++- src/tools/skill-mcp/tools.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/skill-mcp-manager/connection.ts b/src/features/skill-mcp-manager/connection.ts index 79754e712..2826492b0 100644 --- a/src/features/skill-mcp-manager/connection.ts +++ b/src/features/skill-mcp-manager/connection.ts @@ -38,7 +38,8 @@ export async function getOrCreateClient(params: { return pending } - const expandedConfig = expandEnvVarsInObject(config, { trusted: true }) + const isTrusted = info.scope !== "project" + const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted }) let currentConnectionPromise!: Promise state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1) currentConnectionPromise = (async () => { diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts index 197ee62dc..2e1876575 100644 --- a/src/tools/skill-mcp/tools.ts +++ b/src/tools/skill-mcp/tools.ts @@ -166,6 +166,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition serverName: args.mcp_name, skillName: found.skill.name, sessionID, + scope: found.skill.scope, } const context: SkillMcpServerContext = { From 35f778db2dc6753fd3e3f5c6517fe5dc7fc993e9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:11:28 +0900 Subject: [PATCH 19/86] test(skill-mcp): add scope field to test fixtures Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../connection-env-vars.test.ts | 1 + .../skill-mcp-manager/connection-race.test.ts | 1 + .../skill-mcp-manager/manager.test.ts | 45 ++++++++++++++++--- src/tools/skill/mcp-capability-formatter.ts | 1 + 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/features/skill-mcp-manager/connection-env-vars.test.ts b/src/features/skill-mcp-manager/connection-env-vars.test.ts index 157904a00..a535bcb47 100644 --- a/src/features/skill-mcp-manager/connection-env-vars.test.ts +++ b/src/features/skill-mcp-manager/connection-env-vars.test.ts @@ -94,6 +94,7 @@ function createClientInfo(serverName: string): SkillMcpClientInfo { serverName, skillName: "env-skill", sessionID: "session-env", + scope: "builtin", } } diff --git a/src/features/skill-mcp-manager/connection-race.test.ts b/src/features/skill-mcp-manager/connection-race.test.ts index 3fa00b4c3..652987f67 100644 --- a/src/features/skill-mcp-manager/connection-race.test.ts +++ b/src/features/skill-mcp-manager/connection-race.test.ts @@ -95,6 +95,7 @@ function createClientInfo(sessionID: string): SkillMcpClientInfo { serverName: "race-server", skillName: "race-skill", sessionID, + scope: "builtin", } } diff --git a/src/features/skill-mcp-manager/manager.test.ts b/src/features/skill-mcp-manager/manager.test.ts index 66c36b3ba..bdbc316a1 100644 --- a/src/features/skill-mcp-manager/manager.test.ts +++ b/src/features/skill-mcp-manager/manager.test.ts @@ -65,6 +65,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = {} @@ -80,6 +81,7 @@ describe("SkillMcpManager", () => { serverName: "my-mcp", skillName: "data-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = {} @@ -95,6 +97,7 @@ describe("SkillMcpManager", () => { serverName: "custom-server", skillName: "custom-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = {} @@ -112,6 +115,7 @@ describe("SkillMcpManager", () => { serverName: "http-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "http", @@ -130,6 +134,7 @@ describe("SkillMcpManager", () => { serverName: "sse-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "sse", @@ -148,6 +153,7 @@ describe("SkillMcpManager", () => { serverName: "inferred-http", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -165,6 +171,7 @@ describe("SkillMcpManager", () => { serverName: "stdio-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "stdio", @@ -184,6 +191,7 @@ describe("SkillMcpManager", () => { serverName: "inferred-stdio", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { command: "node", @@ -202,6 +210,7 @@ describe("SkillMcpManager", () => { serverName: "mixed-config", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "stdio", @@ -224,6 +233,7 @@ describe("SkillMcpManager", () => { serverName: "bad-url-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "http", @@ -242,6 +252,7 @@ describe("SkillMcpManager", () => { serverName: "http-error-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://nonexistent.example.com/mcp", @@ -259,6 +270,7 @@ describe("SkillMcpManager", () => { serverName: "hint-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://nonexistent.example.com/mcp", @@ -276,6 +288,7 @@ describe("SkillMcpManager", () => { serverName: "mock-test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -302,6 +315,7 @@ describe("SkillMcpManager", () => { serverName: "missing-command", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { type: "stdio", @@ -320,6 +334,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { command: "nonexistent-command-xyz", @@ -338,6 +353,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { command: "nonexistent-command", @@ -358,11 +374,13 @@ describe("SkillMcpManager", () => { serverName: "server1", skillName: "skill1", sessionID: "session-1", + scope: "builtin", } const session2Info: SkillMcpClientInfo = { serverName: "server1", skillName: "skill1", sessionID: "session-2", + scope: "builtin", } // when @@ -396,6 +414,7 @@ describe("SkillMcpManager", () => { serverName: "signal-server", skillName: "signal-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -423,11 +442,12 @@ describe("SkillMcpManager", () => { describe("isConnected", () => { it("returns false for unconnected server", () => { // given - const info: SkillMcpClientInfo = { - serverName: "unknown", - skillName: "test", - sessionID: "session-1", - } + const info: SkillMcpClientInfo = { + serverName: "$1", + skillName: "$2", + sessionID: "$3", + scope: "builtin", + } // when / #then expect(manager.isConnected(info)).toBe(false) @@ -448,6 +468,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const configWithoutEnv: ClaudeCodeMcpServer = { command: "node", @@ -471,6 +492,7 @@ describe("SkillMcpManager", () => { serverName: "test-server", skillName: "test-skill", sessionID: "session-2", + scope: "builtin", } const configWithEnv: ClaudeCodeMcpServer = { command: "node", @@ -498,6 +520,7 @@ describe("SkillMcpManager", () => { serverName: "auth-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -526,6 +549,7 @@ describe("SkillMcpManager", () => { serverName: "no-auth-server", skillName: "test-skill", sessionID: "session-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://example.com/mcp", @@ -546,6 +570,7 @@ describe("SkillMcpManager", () => { serverName: "retry-server", skillName: "retry-skill", sessionID: "session-retry-1", + scope: "builtin", } const context: SkillMcpServerContext = { config: { @@ -584,6 +609,7 @@ describe("SkillMcpManager", () => { serverName: "fail-server", skillName: "fail-skill", sessionID: "session-fail-1", + scope: "builtin", } const context: SkillMcpServerContext = { config: { @@ -615,6 +641,7 @@ describe("SkillMcpManager", () => { serverName: "error-server", skillName: "error-skill", sessionID: "session-error-1", + scope: "builtin", } const context: SkillMcpServerContext = { config: { @@ -653,6 +680,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-server", skillName: "oauth-skill", sessionID: "session-oauth-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -679,6 +707,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-no-token", skillName: "oauth-skill", sessionID: "session-oauth-2", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -705,6 +734,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-with-headers", skillName: "oauth-skill", sessionID: "session-oauth-3", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -734,6 +764,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-refresh", skillName: "oauth-skill", sessionID: "session-oauth-refresh", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -766,6 +797,7 @@ describe("SkillMcpManager", () => { serverName: "oauth-refresh-fallback", skillName: "oauth-skill", sessionID: "session-oauth-refresh-fallback", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -799,6 +831,7 @@ describe("SkillMcpManager", () => { serverName: "no-oauth-server", skillName: "test-skill", sessionID: "session-no-oauth", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -824,6 +857,7 @@ describe("SkillMcpManager", () => { serverName: "stepup-server", skillName: "stepup-skill", sessionID: "session-stepup-1", + scope: "builtin", } const config: ClaudeCodeMcpServer = { url: "https://mcp.example.com/mcp", @@ -869,6 +903,7 @@ describe("SkillMcpManager", () => { serverName: "no-stepup-server", skillName: "no-stepup-skill", sessionID: "session-no-stepup", + scope: "builtin", } const context: SkillMcpServerContext = { config: { diff --git a/src/tools/skill/mcp-capability-formatter.ts b/src/tools/skill/mcp-capability-formatter.ts index a7371480f..6e731bf0d 100644 --- a/src/tools/skill/mcp-capability-formatter.ts +++ b/src/tools/skill/mcp-capability-formatter.ts @@ -23,6 +23,7 @@ export async function formatMcpCapabilities( serverName, skillName: skill.name, sessionID, + scope: skill.scope, } const context: SkillMcpServerContext = { config, From 4f196f4917f93a1da7c562c60d5242ededcecc71 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:12:34 +0900 Subject: [PATCH 20/86] ci: restore mock-isolated test runner Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af24ea533..c2d72bc21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" - name: Run tests - run: bun test + run: bun run script/run-ci-tests.ts typecheck: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 65f97134a..7415257a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -46,7 +46,7 @@ jobs: BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" - name: Run tests - run: bun test + run: bun run script/run-ci-tests.ts typecheck: runs-on: ubuntu-latest From b7d9521a393d003c1120fb57ac016620584076cd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:13:03 +0900 Subject: [PATCH 21/86] feat(installer): add opencode v1.4.0 minimum version check Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- postinstall.mjs | 64 ++++++++++++++++++++++++++++++++++++- src/cli/doctor/constants.ts | 2 +- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/postinstall.mjs b/postinstall.mjs index 5fe05f702..cdebb7e68 100644 --- a/postinstall.mjs +++ b/postinstall.mjs @@ -7,6 +7,60 @@ import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js"; const require = createRequire(import.meta.url); +const MIN_OPENCODE_VERSION = "1.4.0"; + +/** + * Parse version string into numeric parts + * @param {string} version + * @returns {number[]} + */ +function parseVersion(version) { + return version + .replace(/^v/, "") + .split("-")[0] + .split(".") + .map((part) => Number.parseInt(part, 10) || 0); +} + +/** + * Compare two version strings + * @param {string} current + * @param {string} minimum + * @returns {boolean} true if current >= minimum + */ +function compareVersions(current, minimum) { + const currentParts = parseVersion(current); + const minimumParts = parseVersion(minimum); + const length = Math.max(currentParts.length, minimumParts.length); + + for (let index = 0; index < length; index++) { + const currentPart = currentParts[index] ?? 0; + const minimumPart = minimumParts[index] ?? 0; + if (currentPart > minimumPart) return true; + if (currentPart < minimumPart) return false; + } + + return true; +} + +/** + * Check if opencode version meets minimum requirement + * @returns {{ok: boolean, version: string | null}} + */ +function checkOpenCodeVersion() { + try { + const result = require("child_process").execSync("opencode --version", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }); + const version = result.trim(); + const ok = compareVersions(version, MIN_OPENCODE_VERSION); + return { ok, version }; + } catch { + return { ok: true, version: null }; + } +} + /** * Detect libc family on Linux */ @@ -36,7 +90,15 @@ function main() { const { platform, arch } = process; const libcFamily = getLibcFamily(); const packageBaseName = getPackageBaseName(); - + + // Check opencode version requirement + const versionCheck = checkOpenCodeVersion(); + if (versionCheck.version && !versionCheck.ok) { + console.warn(`⚠ oh-my-opencode requires OpenCode >= ${MIN_OPENCODE_VERSION}`); + console.warn(` Detected: ${versionCheck.version}`); + console.warn(` Please update OpenCode to avoid compatibility issues.`); + } + try { const packageCandidates = getPlatformPackageCandidates({ platform, diff --git a/src/cli/doctor/constants.ts b/src/cli/doctor/constants.ts index 9afaf5a88..39bab0568 100644 --- a/src/cli/doctor/constants.ts +++ b/src/cli/doctor/constants.ts @@ -37,7 +37,7 @@ export const EXIT_CODES = { FAILURE: 1, } as const -export const MIN_OPENCODE_VERSION = "1.0.150" +export const MIN_OPENCODE_VERSION = "1.4.0" export const PACKAGE_NAME = PLUGIN_NAME From 8169dbec8975d0b62dc005c50bef8a7bb960ccbd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:13:22 +0900 Subject: [PATCH 22/86] docs: update for v3.16.0 release - Update AGENTS.md header with current date and commit - Update runtime-fallback test to reflect quota STOP classification - Release notes drafted in .sisyphus/drafts/release-notes-v3.16.0.md --- AGENTS.md | 2 +- src/hooks/runtime-fallback/index.test.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 397b18610..86c7d8245 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # oh-my-opencode — O P E N C O D E Plugin -**Generated:** 2026-04-05 | **Commit:** c9be5bb51 | **Branch:** dev +**Generated:** 2026-04-08 | **Commit:** 4f196f49 | **Branch:** dev ## OVERVIEW diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index a1b52c96e..f546716a8 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -282,7 +282,7 @@ describe("runtime-fallback", () => { expect(errorLog).toBeDefined() }) - test("should trigger fallback when session.error says you've reached your usage limit", async () => { + test("should NOT trigger fallback for quota exhaustion without auto-retry signal (STOP classification)", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig({ notify_on_fallback: false }), pluginConfig: createMockPluginConfigWithCategoryFallback(["zai-coding-plan/glm-5.1"]), @@ -308,11 +308,10 @@ describe("runtime-fallback", () => { }) const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback")) - expect(fallbackLog).toBeDefined() - expect(fallbackLog?.data).toMatchObject({ from: "kimi-for-coding/k2p5", to: "zai-coding-plan/glm-5.1" }) + expect(fallbackLog).toBeUndefined() const skipLog = logCalls.find((c) => c.msg.includes("Error not retryable")) - expect(skipLog).toBeUndefined() + expect(skipLog).toBeDefined() }) test("should continue fallback chain when fallback model is not found", async () => { From f2fac9bc0b13cdfe2b97f79279d63c39662da953 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:14:54 +0900 Subject: [PATCH 23/86] test(runtime-fallback): update tests for quota STOP classification Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/runtime-fallback/index.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index f546716a8..f055cde3d 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -2060,7 +2060,7 @@ describe("runtime-fallback", () => { expect(retriedModels).toContain("openai/gpt-5.3-codex") }) - test("triggers fallback when message contains type:error parts (e.g. Minimax insufficient balance)", async () => { + test("does NOT trigger fallback for quota exhaustion in error parts without auto-retry signal (STOP classification)", async () => { const retriedModels: string[] = [] const hook = createRuntimeFallbackHook( @@ -2108,7 +2108,10 @@ describe("runtime-fallback", () => { }, }) - expect(retriedModels).toContain("openai/gpt-5.4") + expect(retriedModels).toHaveLength(0) + + const skipLog = logCalls.find((c) => c.msg.includes("message.updated error not retryable")) + expect(skipLog).toBeDefined() }) test("triggers fallback when message has mixed text and error parts", async () => { From 8090ee6afe43d26d1a0aa9a339429f2c128bd5fb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:20:01 +0900 Subject: [PATCH 24/86] fix(compaction): persist recovery cap across cycles Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/preemptive-compaction-degradation-monitor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/preemptive-compaction-degradation-monitor.ts b/src/hooks/preemptive-compaction-degradation-monitor.ts index 2da8ce27f..6c93a0e4e 100644 --- a/src/hooks/preemptive-compaction-degradation-monitor.ts +++ b/src/hooks/preemptive-compaction-degradation-monitor.ts @@ -85,7 +85,6 @@ export function createPostCompactionDegradationMonitor(args: { postCompactionNoTextStreak.delete(sessionID) postCompactionRecoveryTriggered.delete(sessionID) postCompactionEpoch.delete(sessionID) - postCompactionRecoveryCount.delete(sessionID) } const onSessionCompacted = (sessionID: string): void => { From 14f4390a34e64f38082e8a9e97e930a8a92a0303 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:22:41 +0900 Subject: [PATCH 25/86] fix(ultrawork): add iteration cap to prevent infinite loops Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/ralph-loop/constants.ts | 1 + src/hooks/ralph-loop/loop-state-controller.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/ralph-loop/constants.ts b/src/hooks/ralph-loop/constants.ts index c0a44283a..4d750e98a 100644 --- a/src/hooks/ralph-loop/constants.ts +++ b/src/hooks/ralph-loop/constants.ts @@ -2,5 +2,6 @@ export const HOOK_NAME = "ralph-loop" export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md" export const COMPLETION_TAG_PATTERN = /(.*?)<\/promise>/is export const DEFAULT_MAX_ITERATIONS = 100 +export const ULTRAWORK_MAX_ITERATIONS = 500 export const DEFAULT_COMPLETION_PROMISE = "DONE" export const ULTRAWORK_VERIFICATION_PROMISE = "VERIFIED" diff --git a/src/hooks/ralph-loop/loop-state-controller.ts b/src/hooks/ralph-loop/loop-state-controller.ts index 49be08da2..2a455412a 100644 --- a/src/hooks/ralph-loop/loop-state-controller.ts +++ b/src/hooks/ralph-loop/loop-state-controller.ts @@ -3,6 +3,7 @@ import { DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS, HOOK_NAME, + ULTRAWORK_MAX_ITERATIONS, ULTRAWORK_VERIFICATION_PROMISE, } from "./constants" import { clearState, incrementIteration, readState, writeState } from "./storage" @@ -36,7 +37,7 @@ export function createLoopStateController(options: { active: true, iteration: 1, max_iterations: loopOptions?.ultrawork - ? undefined + ? ULTRAWORK_MAX_ITERATIONS : loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, From c3fe0ae09e62fa980e500e98823a74daaf451489 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:23:08 +0900 Subject: [PATCH 26/86] fix(background): propagate variant in parent notifications Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 84 +++++++++++++++++++ src/features/background-agent/manager.ts | 3 + 2 files changed, 87 insertions(+) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index b2c7606f7..9e10e3c57 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1177,6 +1177,90 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => { }) }) +describe("BackgroundManager.notifyParentSession - variant propagation", () => { + test("should propagate variant in parent notification promptAsync body", async () => { + //#given + const promptCalls: Array<{ body: Record }> = [] + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { path: { id: string }; body: Record }) => { + promptCalls.push({ body: args.body }) + return {} + }, + abort: async () => ({}), + messages: async () => ({ data: [] }), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const task: BackgroundTask = { + id: "task-variant-test", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task with variant", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "high" }, + } + getPendingByParent(manager).set("session-parent", new Set([task.id])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) + .notifyParentSession(task) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0].body.variant).toBe("high") + + manager.shutdown() + }) + + test("should not include variant in promptAsync body when task has no variant", async () => { + //#given + const promptCalls: Array<{ body: Record }> = [] + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { path: { id: string }; body: Record }) => { + promptCalls.push({ body: args.body }) + return {} + }, + abort: async () => ({}), + messages: async () => ({ data: [] }), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const task: BackgroundTask = { + id: "task-no-variant", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task without variant", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + } + getPendingByParent(manager).set("session-parent", new Set([task.id])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) + .notifyParentSession(task) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0].body.variant).toBeUndefined() + + manager.shutdown() + }) +}) + describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => { test("should prepend queued notifications to first text part and clear queue", () => { // given diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 741efc027..1a23c3569 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1840,6 +1840,8 @@ export class BackgroundManager { const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt" const shouldReply = allComplete || isTaskFailure + const variant = task.model?.variant + try { await this.client.session.promptAsync({ path: { id: task.parentSessionID }, @@ -1847,6 +1849,7 @@ export class BackgroundManager { noReply: !shouldReply, ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), + ...(variant !== undefined ? { variant } : {}), ...(resolvedTools ? { tools: resolvedTools } : {}), parts: [createInternalAgentTextPart(notification)], }, From 0bf5dc2629a3dc8282248eb0ecc2838c57ad22fe Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:24:14 +0900 Subject: [PATCH 27/86] fix(token-limit): normalize detection across providers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../token-limit-detection.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/token-limit-detection.ts b/src/hooks/todo-continuation-enforcer/token-limit-detection.ts index 25a2fad3d..366ac245f 100644 --- a/src/hooks/todo-continuation-enforcer/token-limit-detection.ts +++ b/src/hooks/todo-continuation-enforcer/token-limit-detection.ts @@ -1,8 +1,6 @@ -const TOKEN_LIMIT_ERROR_NAMES = new Set([ - "contextlengtherror", -]) +import { isRetryableModelError } from "../../shared/model-error-classifier" -const TOKEN_LIMIT_KEYWORDS = [ +const TOKEN_LIMIT_FALLBACK_PATTERNS = [ "prompt is too long", "is too long", "context_length_exceeded", @@ -11,16 +9,29 @@ const TOKEN_LIMIT_KEYWORDS = [ "too many tokens", ] +const TOKEN_LIMIT_ERROR_NAMES = new Set([ + "contextlengtherror", + "context_length_exceeded", +]) + export function isTokenLimitError(error: { name?: string; message?: string } | undefined): boolean { if (!error) return false - if (error.name && TOKEN_LIMIT_ERROR_NAMES.has(error.name.toLowerCase())) { - return true + const isRetryable = isRetryableModelError({ + name: error.name, + message: error.message, + }) + + if (!isRetryable && error.name) { + const errorNameLower = error.name.toLowerCase() + if (TOKEN_LIMIT_ERROR_NAMES.has(errorNameLower)) { + return true + } } if (error.message) { const lower = error.message.toLowerCase() - return TOKEN_LIMIT_KEYWORDS.some((keyword) => lower.includes(keyword)) + return TOKEN_LIMIT_FALLBACK_PATTERNS.some((pattern) => lower.includes(pattern)) } return false From d490b4dc20259a20a3ef563338d0fc3fd2912a17 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:24:50 +0900 Subject: [PATCH 28/86] fix(ralph-loop): harden Oracle VERIFIED detection Replace fragile regex text matching with structured detection for Oracle verification evidence. - Add oracle-verification-detector.ts with parseOracleVerificationEvidence() - Use structured parsing instead of multiple regex patterns - Add comprehensive test coverage for edge cases - Update completion-promise-detector.ts to use isOracleVerified() - Update pending-verification-handler.ts to use structured extraction Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../ralph-loop/completion-promise-detector.ts | 7 +- .../oracle-verification-detector.test.ts | 294 ++++++++++++++++++ .../oracle-verification-detector.ts | 70 +++++ .../pending-verification-handler.ts | 14 +- 4 files changed, 370 insertions(+), 15 deletions(-) create mode 100644 src/hooks/ralph-loop/oracle-verification-detector.test.ts create mode 100644 src/hooks/ralph-loop/oracle-verification-detector.ts diff --git a/src/hooks/ralph-loop/completion-promise-detector.ts b/src/hooks/ralph-loop/completion-promise-detector.ts index b6e8f38ec..65718e67e 100644 --- a/src/hooks/ralph-loop/completion-promise-detector.ts +++ b/src/hooks/ralph-loop/completion-promise-detector.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs" import { log } from "../../shared/logger" import { HOOK_NAME } from "./constants" import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { isOracleVerified } from "./oracle-verification-detector" import { withTimeout } from "./with-timeout" interface OpenCodeSessionMessage { @@ -17,8 +18,6 @@ interface TranscriptEntry { tool_output?: { output?: string } | string } -const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i - function extractTranscriptEntryText(entry: TranscriptEntry): string { if (typeof entry.content === "string") return entry.content if (typeof entry.tool_output === "string") return entry.tool_output @@ -47,7 +46,7 @@ function shouldInspectSessionMessagePart( return false } - return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(partText) + return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(partText) } function shouldInspectTranscriptEntry( @@ -63,7 +62,7 @@ function shouldInspectTranscriptEntry( return false } - return promise === ULTRAWORK_VERIFICATION_PROMISE && ORACLE_AGENT_PATTERN.test(entryText) + return promise === ULTRAWORK_VERIFICATION_PROMISE && isOracleVerified(entryText) } export function detectCompletionInTranscript( diff --git a/src/hooks/ralph-loop/oracle-verification-detector.test.ts b/src/hooks/ralph-loop/oracle-verification-detector.test.ts new file mode 100644 index 000000000..8b6ef3685 --- /dev/null +++ b/src/hooks/ralph-loop/oracle-verification-detector.test.ts @@ -0,0 +1,294 @@ +/// +import { describe, expect, test } from "bun:test" +import { + extractOracleSessionID, + isOracleVerified, + parseOracleVerificationEvidence, +} from "./oracle-verification-detector" +import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" + +describe("parseOracleVerificationEvidence", () => { + test("#given valid oracle verification text #then should parse all fields", () => { + // #given + const text = `Task completed. + +Agent: oracle + +VERIFIED + + +session_id: ses_oracle_123 +` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("oracle") + expect(evidence?.promise).toBe("VERIFIED") + expect(evidence?.sessionID).toBe("ses_oracle_123") + }) + + test("#given text without agent line #then should return undefined", () => { + // #given + const text = `VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text without promise tag #then should return undefined", () => { + // #given + const text = `Agent: oracle` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text with empty agent #then should return undefined", () => { + // #given + const text = `Agent: + +VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text with empty promise #then should return undefined", () => { + // #given + const text = `Agent: oracle + + ` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given text without metadata #then should parse agent and promise only", () => { + // #given + const text = `Agent: oracle + +VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("oracle") + expect(evidence?.promise).toBe("VERIFIED") + expect(evidence?.sessionID).toBeUndefined() + }) + + test("#given text with metadata but no session_id #then should parse agent and promise only", () => { + // #given + const text = `Agent: oracle + +VERIFIED + + +other_field: value +` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("oracle") + expect(evidence?.promise).toBe("VERIFIED") + expect(evidence?.sessionID).toBeUndefined() + }) + + test("#given empty text #then should return undefined", () => { + // #given + const text = "" + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given whitespace-only text #then should return undefined", () => { + // #given + const text = " \n\t " + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeUndefined() + }) + + test("#given agent with different casing #then should preserve original case", () => { + // #given + const text = `Agent: ORACLE + +VERIFIED` + + // #when + const evidence = parseOracleVerificationEvidence(text) + + // #then + expect(evidence).toBeDefined() + expect(evidence?.agent).toBe("ORACLE") + }) +}) + +describe("isOracleVerified", () => { + test("#given valid oracle verification #then should return true", () => { + // #given + const text = `Agent: oracle + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(true) + }) + + test("#given non-oracle agent #then should return false", () => { + // #given + const text = `Agent: sisyphus + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(false) + }) + + test("#given wrong promise #then should return false", () => { + // #given + const text = `Agent: oracle + +DONE` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(false) + }) + + test("#given oracle agent with different casing #then should return true", () => { + // #given + const text = `Agent: ORACLE + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(true) + }) + + test("#given empty text #then should return false", () => { + // #given + const text = "" + + // #when + const result = isOracleVerified(text) + + // #then + expect(result).toBe(false) + }) +}) + +describe("extractOracleSessionID", () => { + test("#given valid oracle verification with session_id #then should return session_id", () => { + // #given + const text = `Agent: oracle + +${ULTRAWORK_VERIFICATION_PROMISE} + + +session_id: ses_oracle_123 +` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBe("ses_oracle_123") + }) + + test("#given valid oracle verification without session_id #then should return undefined", () => { + // #given + const text = `Agent: oracle + +${ULTRAWORK_VERIFICATION_PROMISE}` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) + + test("#given non-oracle agent #then should return undefined", () => { + // #given + const text = `Agent: sisyphus + +${ULTRAWORK_VERIFICATION_PROMISE} + + +session_id: ses_sis_123 +` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) + + test("#given non-oracle agent with different casing #then should return undefined", () => { + // #given + const text = `Agent: SISYPHUS + +${ULTRAWORK_VERIFICATION_PROMISE} + + +session_id: ses_sis_123 +` + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) + + test("#given empty text #then should return undefined", () => { + // #given + const text = "" + + // #when + const sessionID = extractOracleSessionID(text) + + // #then + expect(sessionID).toBeUndefined() + }) +}) diff --git a/src/hooks/ralph-loop/oracle-verification-detector.ts b/src/hooks/ralph-loop/oracle-verification-detector.ts new file mode 100644 index 000000000..304a38809 --- /dev/null +++ b/src/hooks/ralph-loop/oracle-verification-detector.ts @@ -0,0 +1,70 @@ +import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" + +export interface OracleVerificationEvidence { + agent: string + promise: string + sessionID?: string +} + +const AGENT_LINE_PATTERN = /^Agent:[ \t]*(\S+)$/im +const PROMISE_TAG_PATTERN = /[ \t]*(\S+?)[ \t]*<\/promise>/is +const TASK_METADATA_PATTERN = /[ \t]*([\s\S]*?)[ \t]*<\/task_metadata>/is +const SESSION_ID_LINE_PATTERN = /^session_id:[ \t]*(\S+)$/im + +export function parseOracleVerificationEvidence(text: string): OracleVerificationEvidence | undefined { + const trimmedText = text.trim() + if (!trimmedText) { + return undefined + } + + const agentMatch = trimmedText.match(AGENT_LINE_PATTERN) + if (!agentMatch) { + return undefined + } + const agent = agentMatch[1]?.trim() + if (!agent) { + return undefined + } + + const promiseMatch = trimmedText.match(PROMISE_TAG_PATTERN) + if (!promiseMatch) { + return undefined + } + const promise = promiseMatch[1]?.trim() + if (!promise) { + return undefined + } + + const metadataMatch = trimmedText.match(TASK_METADATA_PATTERN) + let sessionID: string | undefined + if (metadataMatch) { + const metadataContent = metadataMatch[1] + const sessionIDMatch = metadataContent.match(SESSION_ID_LINE_PATTERN) + if (sessionIDMatch) { + sessionID = sessionIDMatch[1]?.trim() + } + } + + return { agent, promise, sessionID } +} + +export function isOracleVerified(text: string): boolean { + const evidence = parseOracleVerificationEvidence(text) + if (!evidence) { + return false + } + + const isOracleAgent = evidence.agent.toLowerCase() === "oracle" + const isVerifiedPromise = evidence.promise === ULTRAWORK_VERIFICATION_PROMISE + + return isOracleAgent && isVerifiedPromise +} + +export function extractOracleSessionID(text: string): string | undefined { + const evidence = parseOracleVerificationEvidence(text) + if (!evidence || evidence.agent.toLowerCase() !== "oracle") { + return undefined + } + + return evidence.sessionID +} diff --git a/src/hooks/ralph-loop/pending-verification-handler.ts b/src/hooks/ralph-loop/pending-verification-handler.ts index 00878ca91..420a2f935 100644 --- a/src/hooks/ralph-loop/pending-verification-handler.ts +++ b/src/hooks/ralph-loop/pending-verification-handler.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared/logger" import { HOOK_NAME } from "./constants" -import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants" +import { extractOracleSessionID, isOracleVerified } from "./oracle-verification-detector" import type { RalphLoopState } from "./types" import { handleFailedVerification } from "./verification-failure-handler" import { withTimeout } from "./with-timeout" @@ -11,13 +11,6 @@ type OpenCodeSessionMessage = { parts?: Array<{ type?: string; text?: string }> } -const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i -const TASK_METADATA_SESSION_PATTERN = /[\s\S]*?session_id:\s*([^\s<]+)[\s\S]*?<\/task_metadata>/i -const VERIFIED_PROMISE_PATTERN = new RegExp( - `\\s*${ULTRAWORK_VERIFICATION_PROMISE}\\s*<\\/promise>`, - "i", -) - function collectAssistantText(message: OpenCodeSessionMessage): string { if (!Array.isArray(message.parts)) { return "" @@ -67,12 +60,11 @@ async function detectOracleVerificationFromParentSession( } const assistantText = collectAssistantText(message) - if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) { + if (!isOracleVerified(assistantText)) { continue } - const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN) - const detectedOracleSessionID = sessionMatch?.[1]?.trim() + const detectedOracleSessionID = extractOracleSessionID(assistantText) if (detectedOracleSessionID) { return detectedOracleSessionID } From a09b905b181971c445ee770e9d9406be63a8b570 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:26:22 +0900 Subject: [PATCH 29/86] fix(installer): add installedVersion to DetectedConfig type Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/types.ts b/src/cli/types.ts index 7cffad1f2..a8f785cb0 100644 --- a/src/cli/types.ts +++ b/src/cli/types.ts @@ -34,6 +34,7 @@ export interface ConfigMergeResult { export interface DetectedConfig { isInstalled: boolean + installedVersion: string | null hasClaude: boolean isMax20: boolean hasOpenAI: boolean From 7022a7e85d393ecb5717772bd92143c7b1bab0e9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:26:26 +0900 Subject: [PATCH 30/86] feat(installer): add version compatibility checking utilities Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../version-compatibility.test.ts | 82 ++++++++++++++ .../config-manager/version-compatibility.ts | 103 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/cli/config-manager/version-compatibility.test.ts create mode 100644 src/cli/config-manager/version-compatibility.ts diff --git a/src/cli/config-manager/version-compatibility.test.ts b/src/cli/config-manager/version-compatibility.test.ts new file mode 100644 index 000000000..95f743452 --- /dev/null +++ b/src/cli/config-manager/version-compatibility.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test" +import { + checkVersionCompatibility, + extractVersionFromPluginEntry, +} from "./version-compatibility" + +describe("checkVersionCompatibility", () => { + it("allows fresh install when no current version", () => { + const result = checkVersionCompatibility(null, "3.15.0") + expect(result.canUpgrade).toBe(true) + expect(result.isDowngrade).toBe(false) + expect(result.requiresMigration).toBe(false) + }) + + it("detects same version as already installed", () => { + const result = checkVersionCompatibility("3.15.0", "3.15.0") + expect(result.canUpgrade).toBe(true) + expect(result.reason).toContain("already installed") + }) + + it("blocks downgrade from higher to lower version", () => { + const result = checkVersionCompatibility("3.15.0", "3.14.0") + expect(result.canUpgrade).toBe(false) + expect(result.isDowngrade).toBe(true) + expect(result.reason).toContain("Downgrade") + }) + + it("allows patch version upgrade", () => { + const result = checkVersionCompatibility("3.15.0", "3.15.1") + expect(result.canUpgrade).toBe(true) + expect(result.isMajorBump).toBe(false) + expect(result.requiresMigration).toBe(false) + }) + + it("allows minor version upgrade", () => { + const result = checkVersionCompatibility("3.15.0", "3.16.0") + expect(result.canUpgrade).toBe(true) + expect(result.isMajorBump).toBe(false) + expect(result.requiresMigration).toBe(false) + }) + + it("detects major version bump requiring migration", () => { + const result = checkVersionCompatibility("3.15.0", "4.0.0") + expect(result.canUpgrade).toBe(true) + expect(result.isMajorBump).toBe(true) + expect(result.requiresMigration).toBe(true) + expect(result.reason).toContain("Major version upgrade") + }) + + it("handles v prefix in versions", () => { + const result = checkVersionCompatibility("v3.15.0", "v3.16.0") + expect(result.canUpgrade).toBe(true) + expect(result.isDowngrade).toBe(false) + }) + + it("handles mixed v prefix", () => { + const result = checkVersionCompatibility("3.15.0", "v3.16.0") + expect(result.canUpgrade).toBe(true) + }) +}) + +describe("extractVersionFromPluginEntry", () => { + it("extracts version from canonical plugin entry", () => { + const version = extractVersionFromPluginEntry("oh-my-openagent@3.15.0") + expect(version).toBe("3.15.0") + }) + + it("extracts version from legacy plugin entry", () => { + const version = extractVersionFromPluginEntry("oh-my-opencode@3.14.0") + expect(version).toBe("3.14.0") + }) + + it("returns null for bare plugin entry", () => { + const version = extractVersionFromPluginEntry("oh-my-openagent") + expect(version).toBeNull() + }) + + it("handles prerelease versions", () => { + const version = extractVersionFromPluginEntry("oh-my-openagent@3.16.0-beta.1") + expect(version).toBe("3.16.0-beta.1") + }) +}) diff --git a/src/cli/config-manager/version-compatibility.ts b/src/cli/config-manager/version-compatibility.ts new file mode 100644 index 000000000..1042dc1d6 --- /dev/null +++ b/src/cli/config-manager/version-compatibility.ts @@ -0,0 +1,103 @@ +export interface VersionCompatibility { + canUpgrade: boolean + reason?: string + isDowngrade: boolean + isMajorBump: boolean + requiresMigration: boolean +} + +function parseVersion(version: string): number[] { + const clean = version.replace(/^v/, "").split("-")[0] + return clean.split(".").map(Number) +} + +function compareVersions(a: string, b: string): number { + const partsA = parseVersion(a) + const partsB = parseVersion(b) + const maxLen = Math.max(partsA.length, partsB.length) + + for (let i = 0; i < maxLen; i++) { + const numA = partsA[i] ?? 0 + const numB = partsB[i] ?? 0 + if (numA !== numB) { + return numA - numB + } + } + + return 0 +} + +export function checkVersionCompatibility( + currentVersion: string | null, + newVersion: string +): VersionCompatibility { + if (!currentVersion) { + return { + canUpgrade: true, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } + + const cleanCurrent = currentVersion.replace(/^v/, "") + const cleanNew = newVersion.replace(/^v/, "") + + try { + const comparison = compareVersions(cleanNew, cleanCurrent) + + if (comparison < 0) { + return { + canUpgrade: false, + reason: `Downgrade from ${currentVersion} to ${newVersion} is not allowed`, + isDowngrade: true, + isMajorBump: false, + requiresMigration: false, + } + } + + if (comparison === 0) { + return { + canUpgrade: true, + reason: `Version ${newVersion} is already installed`, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } + + const currentMajor = cleanCurrent.split(".")[0] + const newMajor = cleanNew.split(".")[0] + const isMajorBump = currentMajor !== newMajor + + if (isMajorBump) { + return { + canUpgrade: true, + reason: `Major version upgrade from ${currentVersion} to ${newVersion} - configuration migration may be required`, + isDowngrade: false, + isMajorBump: true, + requiresMigration: true, + } + } + + return { + canUpgrade: true, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } catch { + return { + canUpgrade: true, + reason: `Unable to compare versions ${currentVersion} and ${newVersion} - proceeding with caution`, + isDowngrade: false, + isMajorBump: false, + requiresMigration: false, + } + } +} + +export function extractVersionFromPluginEntry(entry: string): string | null { + const match = entry.match(/@(.+)$/) + return match ? match[1] : null +} From 2f86a17516ee33a348b7bf4c73e61d8cb81ef2d3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:26:27 +0900 Subject: [PATCH 31/86] feat(installer): add config backup utility for safe upgrades Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/config-manager/backup-config.ts | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/cli/config-manager/backup-config.ts diff --git a/src/cli/config-manager/backup-config.ts b/src/cli/config-manager/backup-config.ts new file mode 100644 index 000000000..682c5dd55 --- /dev/null +++ b/src/cli/config-manager/backup-config.ts @@ -0,0 +1,32 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs" +import { dirname } from "node:path" + +export interface BackupResult { + success: boolean + backupPath?: string + error?: string +} + +export function backupConfigFile(configPath: string): BackupResult { + if (!existsSync(configPath)) { + return { success: true } + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const backupPath = `${configPath}.backup-${timestamp}` + + try { + const dir = dirname(backupPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + copyFileSync(configPath, backupPath) + return { success: true, backupPath } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to create backup", + } + } +} From 01f7d5e2a8600fbf0af53f25c9ae3cd3205f8df2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:26:29 +0900 Subject: [PATCH 32/86] feat(installer): export version compatibility and backup utilities Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/config-manager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 73a81ad6a..43cbd6dab 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -18,3 +18,12 @@ export { detectCurrentConfig } from "./config-manager/detect-current-config" export type { BunInstallResult } from "./config-manager/bun-install" export { runBunInstall, runBunInstallWithDetails } from "./config-manager/bun-install" + +export type { VersionCompatibility } from "./config-manager/version-compatibility" +export { + checkVersionCompatibility, + extractVersionFromPluginEntry, +} from "./config-manager/version-compatibility" + +export type { BackupResult } from "./config-manager/backup-config" +export { backupConfigFile } from "./config-manager/backup-config" From cdd6e88557e3f3bf133ed4fb82a380048d66a4ae Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:26:33 +0900 Subject: [PATCH 33/86] fix(installer): add upgrade path safety checks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../add-plugin-to-opencode-config.ts | 25 +++++++++++++++++++ .../config-manager/detect-current-config.ts | 13 +++++++++- .../config-manager/write-omo-config.test.ts | 1 + src/cli/config-manager/write-omo-config.ts | 10 ++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 19b265ec5..208abd56a 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -1,12 +1,14 @@ import { readFileSync, writeFileSync } from "node:fs" import type { ConfigMergeResult } from "../types" import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared" +import { backupConfigFile } from "./backup-config" import { getConfigDir } from "./config-context" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" import { formatErrorWithSuggestion } from "./format-error-with-suggestion" import { detectConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file" import { getPluginNameWithVersion } from "./plugin-name-with-version" +import { checkVersionCompatibility, extractVersionFromPluginEntry } from "./version-compatibility" export async function addPluginToOpenCodeConfig(currentVersion: string): Promise { try { @@ -52,6 +54,29 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise && !(plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)) ) + const existingEntry = canonicalEntries[0] ?? legacyEntries[0] + if (existingEntry) { + const installedVersion = extractVersionFromPluginEntry(existingEntry) + const compatibility = checkVersionCompatibility(installedVersion, currentVersion) + + if (!compatibility.canUpgrade) { + return { + success: false, + configPath: path, + error: compatibility.reason ?? "Version compatibility check failed", + } + } + + const backupResult = backupConfigFile(path) + if (!backupResult.success) { + return { + success: false, + configPath: path, + error: `Failed to create backup: ${backupResult.error}`, + } + } + } + const normalizedPlugins = [...otherPlugins] if (canonicalEntries.length > 0) { diff --git a/src/cli/config-manager/detect-current-config.ts b/src/cli/config-manager/detect-current-config.ts index 3679d5bd6..f158e18e2 100644 --- a/src/cli/config-manager/detect-current-config.ts +++ b/src/cli/config-manager/detect-current-config.ts @@ -4,6 +4,7 @@ import type { DetectedConfig } from "../types" import { getOmoConfigPath } from "./config-context" import { detectConfigFormat } from "./opencode-config-format" import { parseOpenCodeConfigFileWithError } from "./parse-opencode-config-file" +import { extractVersionFromPluginEntry } from "./version-compatibility" function detectProvidersFromOmoConfig(): { hasOpenAI: boolean @@ -60,9 +61,14 @@ function isOurPlugin(plugin: string): boolean { plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`) } +function findOurPluginEntry(plugins: string[]): string | null { + return plugins.find(isOurPlugin) ?? null +} + export function detectCurrentConfig(): DetectedConfig { const result: DetectedConfig = { isInstalled: false, + installedVersion: null, hasClaude: true, isMax20: true, hasOpenAI: true, @@ -86,7 +92,12 @@ export function detectCurrentConfig(): DetectedConfig { const openCodeConfig = parseResult.config const plugins = openCodeConfig.plugin ?? [] - result.isInstalled = plugins.some(isOurPlugin) + const ourPluginEntry = findOurPluginEntry(plugins) + result.isInstalled = !!ourPluginEntry + + if (ourPluginEntry) { + result.installedVersion = extractVersionFromPluginEntry(ourPluginEntry) + } if (!result.isInstalled) { return result diff --git a/src/cli/config-manager/write-omo-config.test.ts b/src/cli/config-manager/write-omo-config.test.ts index 5701b53dc..48ae5c620 100644 --- a/src/cli/config-manager/write-omo-config.test.ts +++ b/src/cli/config-manager/write-omo-config.test.ts @@ -18,6 +18,7 @@ const installConfig: InstallConfig = { hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, + hasOpencodeGo: false, } function getRecord(value: unknown): Record { diff --git a/src/cli/config-manager/write-omo-config.ts b/src/cli/config-manager/write-omo-config.ts index 261175e7a..697322584 100644 --- a/src/cli/config-manager/write-omo-config.ts +++ b/src/cli/config-manager/write-omo-config.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs" import { parseJsonc } from "../../shared" import type { ConfigMergeResult, InstallConfig } from "../types" +import { backupConfigFile } from "./backup-config" import { getConfigDir, getOmoConfigPath } from "./config-context" import { deepMergeRecord } from "./deep-merge-record" import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists" @@ -28,6 +29,15 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult const newConfig = generateOmoConfig(installConfig) if (existsSync(omoConfigPath)) { + const backupResult = backupConfigFile(omoConfigPath) + if (!backupResult.success) { + return { + success: false, + configPath: omoConfigPath, + error: `Failed to create backup: ${backupResult.error}`, + } + } + try { const stat = statSync(omoConfigPath) const content = readFileSync(omoConfigPath, "utf-8") From 7be285ef89daac151bd3badfa01f2bff67b89206 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:28:08 +0900 Subject: [PATCH 34/86] feat(oauth): add per-server refresh mutex Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/mcp-oauth/refresh-mutex.ts | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/features/mcp-oauth/refresh-mutex.ts diff --git a/src/features/mcp-oauth/refresh-mutex.ts b/src/features/mcp-oauth/refresh-mutex.ts new file mode 100644 index 000000000..3b7c3e710 --- /dev/null +++ b/src/features/mcp-oauth/refresh-mutex.ts @@ -0,0 +1,58 @@ +import type { OAuthTokenData } from "./storage" + +/** + * Per-server OAuth refresh mutex to prevent concurrent refresh race conditions. + * + * When multiple operations need to refresh a token for the same server, + * this ensures only one refresh request is made and all waiters receive + * the same result. + */ + +const ongoingRefreshes = new Map>() + +/** + * Execute a token refresh with per-server mutual exclusion. + * + * If a refresh is already in progress for the given server, this will + * return the same promise to all concurrent callers. Once the refresh + * completes (success or failure), the lock is released. + * + * @param serverUrl - The OAuth server URL (used as mutex key) + * @param refreshFn - The actual refresh operation to execute + * @returns Promise that resolves to the new token data + */ +export async function withRefreshMutex( + serverUrl: string, + refreshFn: () => Promise, +): Promise { + const existing = ongoingRefreshes.get(serverUrl) + if (existing) { + return existing + } + + const refreshPromise = refreshFn().finally(() => { + ongoingRefreshes.delete(serverUrl) + }) + + ongoingRefreshes.set(serverUrl, refreshPromise) + return refreshPromise +} + +/** + * Check if a refresh is currently in progress for a server. + * + * @param serverUrl - The OAuth server URL + * @returns true if a refresh operation is active + */ +export function isRefreshInProgress(serverUrl: string): boolean { + return ongoingRefreshes.has(serverUrl) +} + +/** + * Get the number of servers currently undergoing token refresh. + * + * @returns Number of active refresh operations + */ +export function getActiveRefreshCount(): number { + return ongoingRefreshes.size +} From 7ba41d388ae011c19cd2b1e8924a98d29911d8c3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:28:14 +0900 Subject: [PATCH 35/86] fix(oauth): atomic storage writes for token safety Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/mcp-oauth/storage.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/features/mcp-oauth/storage.ts b/src/features/mcp-oauth/storage.ts index d041bdfd1..2c705f5b7 100644 --- a/src/features/mcp-oauth/storage.ts +++ b/src/features/mcp-oauth/storage.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" import { dirname, join } from "node:path" import { getOpenCodeConfigDir } from "../../shared" @@ -82,8 +82,10 @@ function writeStore(store: TokenStore): boolean { mkdirSync(dir, { recursive: true }) } - writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 }) - chmodSync(filePath, 0o600) + const tempPath = `${filePath}.tmp.${Date.now()}` + writeFileSync(tempPath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 }) + chmodSync(tempPath, 0o600) + renameSync(tempPath, filePath) return true } catch { return false From ab0e1e3be018458eb61a1ab15356ffb460b3321b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:28:17 +0900 Subject: [PATCH 36/86] fix(oauth): refresh-once handler for 401/403 responses Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../skill-mcp-manager/oauth-handler.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/features/skill-mcp-manager/oauth-handler.ts b/src/features/skill-mcp-manager/oauth-handler.ts index 5e76a2f81..d1b2b7513 100644 --- a/src/features/skill-mcp-manager/oauth-handler.ts +++ b/src/features/skill-mcp-manager/oauth-handler.ts @@ -116,3 +116,42 @@ export async function handleStepUpIfNeeded(params: { return false } } + +export async function handlePostRequestAuthError(params: { + error: Error + config: ClaudeCodeMcpServer + authProviders: Map + createOAuthProvider?: OAuthProviderFactory + refreshAttempted?: Set +}): Promise { + const { error, config, authProviders, createOAuthProvider, refreshAttempted = new Set() } = params + + if (!config.oauth || !config.url) { + return false + } + + const statusMatch = /\b(401|403)\b/.exec(error.message) + if (!statusMatch) { + return false + } + + const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider) + const tokenData = provider.tokens() + + if (!tokenData?.refreshToken) { + return false + } + + if (refreshAttempted.has(config.url)) { + return false + } + + refreshAttempted.add(config.url) + + try { + await provider.refresh(tokenData.refreshToken) + return true + } catch { + return false + } +} From 1a04a6effbc8201563bbf0718875c51d2cff78b9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:33:09 +0900 Subject: [PATCH 37/86] test(ralph-loop): update iteration cap expectation to 500 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/ralph-loop/ulw-loop-verification.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/ralph-loop/ulw-loop-verification.test.ts b/src/hooks/ralph-loop/ulw-loop-verification.test.ts index 1f2edfa85..54041f452 100644 --- a/src/hooks/ralph-loop/ulw-loop-verification.test.ts +++ b/src/hooks/ralph-loop/ulw-loop-verification.test.ts @@ -279,8 +279,8 @@ describe("ulw-loop verification", () => { await hook.event({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) expect(hook.getState()?.iteration).toBe(2) - expect(hook.getState()?.max_iterations).toBeUndefined() - expect(promptCalls[0].text).toContain("2/unbounded") + expect(hook.getState()?.max_iterations).toBe(500) + expect(promptCalls[0].text).toContain("2/500") }) test("#given prior transcript completion from older run #when new ulw loop starts #then old completion is ignored", async () => { From 94449e0a24993399832aad6e50c4aba557577e9a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:33:18 +0900 Subject: [PATCH 38/86] test(delegate-task): update isPlanAgent test for exact match fix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/tools/delegate-task/tools.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index df7ed517c..7c09f16ab 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -180,8 +180,8 @@ describe("sisyphus-task", () => { //#given / #when const result = isPlanAgent("planner") - //#then - "planner" contains "plan" so it matches via includes - expect(result).toBe(true) + //#then - "planner" is NOT an exact match for "plan" (T37 exact match fix) + expect(result).toBe(false) }) test("returns true for case-insensitive match 'PLAN'", () => { From 4d9652c0281802e716162525e4b9deafb55240ad Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:33:19 +0900 Subject: [PATCH 39/86] test(start-work): update display name expectations for ZWSP fix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/start-work/index.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index 1c1c20ae6..e51973a4e 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" import { createStartWorkHook } from "./index" import { createAtlasHook } from "../atlas" -import { getAgentListDisplayName } from "../../shared/agent-display-names" +import { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names" import { writeBoulderState, clearBoulderState, @@ -482,7 +482,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe(getAgentDisplayName("atlas")) }) test("should switch to Atlas even when current session is Sisyphus (regression: #3155)", async () => { @@ -502,7 +502,7 @@ You are starting a Sisyphus work session. ) // atlas is registered in beforeEach, so it must be selected - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe(getAgentDisplayName("atlas")) expect(sessionState.getSessionAgent("ses-sisyphus-to-atlas")).toBe("atlas") }) @@ -623,7 +623,7 @@ You are starting a Sisyphus work session. await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe(getAgentDisplayName("atlas")) expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) @@ -713,7 +713,7 @@ You are starting a Sisyphus work session. await firePendingTimers() // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe(getAgentDisplayName("atlas")) expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) From 902b2f9f58ac8c49b15c4ae169bc7e0e9f1a6bd8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:40:38 +0900 Subject: [PATCH 40/86] test(ci): update workflow test to match actual CI command Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- script/publish-workflow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/publish-workflow.test.ts b/script/publish-workflow.test.ts index 9604fc217..f1f45eb5b 100644 --- a/script/publish-workflow.test.ts +++ b/script/publish-workflow.test.ts @@ -15,7 +15,7 @@ describe("test workflows", () => { const workflow = readFileSync(workflowPath, "utf8") expect(workflow).toContain("- name: Run tests") - expect(workflow).toContain("run: bun test") + expect(workflow).toMatch(/run: bun (test|run script\/run-ci-tests\.ts)/) } }) }) From 2c6a161441d02a9deebb56631bdb46830265da6d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:40:40 +0900 Subject: [PATCH 41/86] test(runtime-fallback): fix OpenAI auto-retry test expectations - Add timeout_seconds to mock config for auto-retry signal detection - Add 'usage limit' pattern to quota_exceeded error classification Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/runtime-fallback/error-classifier.ts | 3 ++- src/hooks/runtime-fallback/index.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/runtime-fallback/error-classifier.ts b/src/hooks/runtime-fallback/error-classifier.ts index 962e831b1..7ba5aa491 100644 --- a/src/hooks/runtime-fallback/error-classifier.ts +++ b/src/hooks/runtime-fallback/error-classifier.ts @@ -131,7 +131,8 @@ export function classifyErrorType(error: unknown): string | undefined { /billing.?(?:hard.?)?limit/i.test(message) || /exhausted\s+your\s+capacity/i.test(message) || /out\s+of\s+credits?/i.test(message) || - /payment.?required/i.test(message) + /payment.?required/i.test(message) || + /usage\s+limit/i.test(message) ) { return "quota_exceeded" } diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index f055cde3d..df4f7cd3c 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -518,7 +518,7 @@ describe("runtime-fallback", () => { test("should trigger fallback on OpenAI auto-retry signal in message.updated", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { - config: createMockConfig({ notify_on_fallback: false }), + config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 30 }), pluginConfig: createMockPluginConfigWithCategoryFallback(["anthropic/claude-opus-4-6"]), }) From d22c3f23db85c8e8d48589fc4a03f27f7989a7e3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 13:40:43 +0900 Subject: [PATCH 42/86] test(plugin-interface): fix Atlas display name expectation - Update expected value to match actual output after ZWSP prefix stripping Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin-interface.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts index a3699668c..86f509de1 100644 --- a/src/plugin-interface.test.ts +++ b/src/plugin-interface.test.ts @@ -165,7 +165,7 @@ describe("createPluginInterface - command.execute.before", () => { ) // then - expect(output.message.agent).toBe(getAgentListDisplayName("atlas")) + expect(output.message.agent).toBe("Atlas - Plan Executor") expect(getSessionAgent("ses-command-atlas")).toBe("atlas") expect(readBoulderState(testDir)?.agent).toBe("atlas") }) From c2816e728c06c542b986440f7bcc4f2d4ba2e34c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 05:42:35 +0000 Subject: [PATCH 43/86] @dhruvkej9 has signed the CLA in code-yeongyu/oh-my-openagent#3217 --- signatures/cla.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index a0b764eb3..3d0f801c1 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2607,6 +2607,22 @@ "created_at": "2026-04-07T13:06:07Z", "repoId": 1108837393, "pullRequestNo": 3203 + }, + { + "name": "dhruvkej9", + "id": 96516827, + "comment_id": 4204071246, + "created_at": "2026-04-08T05:36:52Z", + "repoId": 1108837393, + "pullRequestNo": 3217 + }, + { + "name": "dhruvkej9", + "id": 96516827, + "comment_id": 4204084942, + "created_at": "2026-04-08T05:40:40Z", + "repoId": 1108837393, + "pullRequestNo": 3217 } ] } \ No newline at end of file From b28567251d5d6a365e79068d156f22af84394ded Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 15:28:17 +0900 Subject: [PATCH 44/86] fix(plugin-loader): filter project-scoped plugins by cwd discoverInstalledPlugins read scope from installed_plugins.json but never filtered by it, so project/local scoped Claude Code plugins leaked into every session regardless of process.cwd(). Add projectPath to PluginInstallation and InstalledPluginEntryV3, propagate it through v3EntryToInstallation, and introduce shouldLoadPluginForCwd which reuses shared/contains-path for safe symlink- and ancestor-aware matching and expands a leading tilde. user and managed scopes still always load; project and local without a projectPath are skipped as a safe default. Covered by 13 new shouldLoadPluginForCwd unit tests (including tilde expansion against a mocked homedir) and 13 new discoverInstalledPlugins integration tests spanning v1, v2, and v3 database formats plus the existing enabledPluginsOverride path. Fixes #3216 --- .../discovery.test.ts | 497 ++++++++++++++++++ .../claude-code-plugin-loader/discovery.ts | 11 + .../scope-filter.test.ts | 244 +++++++++ .../claude-code-plugin-loader/scope-filter.ts | 29 + .../claude-code-plugin-loader/types.ts | 11 + 5 files changed, 792 insertions(+) create mode 100644 src/features/claude-code-plugin-loader/scope-filter.test.ts create mode 100644 src/features/claude-code-plugin-loader/scope-filter.ts diff --git a/src/features/claude-code-plugin-loader/discovery.test.ts b/src/features/claude-code-plugin-loader/discovery.test.ts index 6e3e1cd34..2d4930ac0 100644 --- a/src/features/claude-code-plugin-loader/discovery.test.ts +++ b/src/features/claude-code-plugin-loader/discovery.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path" const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME const temporaryDirectories: string[] = [] +const originalCwd = process.cwd() function createTemporaryDirectory(prefix: string): string { const directory = mkdtempSync(join(tmpdir(), prefix)) @@ -17,6 +18,14 @@ function createTemporaryDirectory(prefix: string): string { return directory } +function writeDatabase(pluginsHome: string, database: unknown): void { + writeFileSync(join(pluginsHome, "installed_plugins.json"), JSON.stringify(database), "utf-8") +} + +function createInstallPath(prefix: string): string { + return createTemporaryDirectory(prefix) +} + describe("discoverInstalledPlugins", () => { beforeEach(() => { mock.module("../../shared/logger", () => ({ @@ -36,6 +45,10 @@ describe("discoverInstalledPlugins", () => { process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome } + if (process.cwd() !== originalCwd) { + process.chdir(originalCwd) + } + for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }) } @@ -156,4 +169,488 @@ describe("discoverInstalledPlugins", () => { expect(discovered.plugins).toHaveLength(1) expect(discovered.plugins[0]?.name).toBe("oh-my-openagent") }) + + describe("#given project-scoped entries in v1 format", () => { + it("#when cwd matches projectPath #then the plugin loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v1-project-match-") + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "project-plugin@market": { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-match`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("project-plugin") + }) + + it("#when cwd is a subdirectory of projectPath #then the plugin loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v1-project-sub-") + const subdirectory = join(projectDirectory, "packages", "app") + mkdirSync(subdirectory, { recursive: true }) + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "sub-plugin@market": { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(subdirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-sub`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("sub-plugin") + }) + + it("#when cwd does not match projectPath #then the plugin is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v1-project-miss-") + const otherDirectory = createTemporaryDirectory("omo-v1-other-") + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "outside-plugin@market": { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(otherDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-miss`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when projectPath is missing #then the plugin is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "no-path-plugin@market": { + scope: "project", + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-noproj`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when scope is user #then it always loads regardless of cwd", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const unrelatedDirectory = createTemporaryDirectory("omo-v1-unrelated-") + const installPath = createInstallPath("omo-v1-install-") + writeDatabase(pluginsHome, { + version: 1, + plugins: { + "user-plugin@market": { + scope: "user", + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + }, + }) + process.chdir(unrelatedDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v1-user`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("user-plugin") + }) + }) + + describe("#given project and local scoped entries in v2 format", () => { + it("#when cwd matches project-scoped projectPath #then it loads while non-matching entries are dropped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-project-") + const otherDirectory = createTemporaryDirectory("omo-v2-other-") + const matchingInstall = createInstallPath("omo-v2-match-install-") + const missingInstall = createInstallPath("omo-v2-miss-install-") + const userInstall = createInstallPath("omo-v2-user-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "matching-project@market": [ + { + scope: "project", + projectPath: projectDirectory, + installPath: matchingInstall, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + "other-project@market": [ + { + scope: "project", + projectPath: otherDirectory, + installPath: missingInstall, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + "global-user@market": [ + { + scope: "user", + installPath: userInstall, + version: "2.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-mix`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + const names = discovered.plugins.map((plugin) => plugin.name).sort() + expect(names).toEqual(["global-user", "matching-project"]) + }) + + it("#when scope is local and cwd matches projectPath #then it loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-local-match-") + const installPath = createInstallPath("omo-v2-local-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "local-plugin@market": [ + { + scope: "local", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-match`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("local-plugin") + }) + + it("#when scope is local and cwd does not match projectPath #then it is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-local-miss-") + const otherDirectory = createTemporaryDirectory("omo-v2-local-other-") + const installPath = createInstallPath("omo-v2-local-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "local-plugin@market": [ + { + scope: "local", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(otherDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-local-miss`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when multiple installations are present #then only the first is considered and scope filtering still applies", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v2-multi-") + const otherDirectory = createTemporaryDirectory("omo-v2-multi-other-") + const primaryInstall = createInstallPath("omo-v2-multi-primary-") + const secondaryInstall = createInstallPath("omo-v2-multi-secondary-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "multi-plugin@market": [ + { + scope: "project", + projectPath: otherDirectory, + installPath: primaryInstall, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + { + scope: "project", + projectPath: projectDirectory, + installPath: secondaryInstall, + version: "2.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v2-multi`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then — existing behavior keeps only the first entry; with scope filter it is + // (correctly) skipped because the first entry points at a different project. + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + }) + + describe("#given project and local scoped entries in v3 flat-array format", () => { + it("#when cwd matches projectPath #then projectPath flows through and the plugin loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v3-match-") + const installPath = createInstallPath("omo-v3-install-") + writeDatabase(pluginsHome, [ + { + name: "v3-project-plugin", + marketplace: "market", + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ]) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-match`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("v3-project-plugin") + }) + + it("#when cwd does not match projectPath #then the plugin is skipped", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-v3-miss-") + const otherDirectory = createTemporaryDirectory("omo-v3-miss-other-") + const installPath = createInstallPath("omo-v3-install-") + writeDatabase(pluginsHome, [ + { + name: "v3-skipped-plugin", + marketplace: "market", + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + lastUpdated: "2026-03-25T00:00:00Z", + }, + { + name: "v3-user-plugin", + marketplace: "market", + scope: "user", + installPath: createInstallPath("omo-v3-user-install-"), + version: "2.0.0", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ]) + process.chdir(otherDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-v3-miss`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("v3-user-plugin") + }) + }) + + describe("#given enabledPluginsOverride combined with scope filtering", () => { + it("#when a project-scoped plugin is disabled via override #then it is still skipped even if cwd would match", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-enabled-proj-") + const installPath = createInstallPath("omo-enabled-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "gated-plugin@market": [ + { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-off`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + enabledPluginsOverride: { "gated-plugin@market": false }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(0) + }) + + it("#when a project-scoped plugin is enabled and cwd matches #then it loads", async () => { + //#given + const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string + const projectDirectory = createTemporaryDirectory("omo-enabled-match-") + const installPath = createInstallPath("omo-enabled-match-install-") + writeDatabase(pluginsHome, { + version: 2, + plugins: { + "enabled-plugin@market": [ + { + scope: "project", + projectPath: projectDirectory, + installPath, + version: "1.0.0", + installedAt: "2026-03-25T00:00:00Z", + lastUpdated: "2026-03-25T00:00:00Z", + }, + ], + }, + }) + process.chdir(projectDirectory) + + //#when + const { discoverInstalledPlugins } = await import(`./discovery?t=${Date.now()}-enabled-on`) + const discovered = discoverInstalledPlugins({ + pluginsHomeOverride: pluginsHome, + loadPluginManifestOverride: () => null, + enabledPluginsOverride: { "enabled-plugin@market": true }, + }) + + //#then + expect(discovered.errors).toHaveLength(0) + expect(discovered.plugins).toHaveLength(1) + expect(discovered.plugins[0]?.name).toBe("enabled-plugin") + }) + }) }) diff --git a/src/features/claude-code-plugin-loader/discovery.ts b/src/features/claude-code-plugin-loader/discovery.ts index f4781fef3..4a633782b 100644 --- a/src/features/claude-code-plugin-loader/discovery.ts +++ b/src/features/claude-code-plugin-loader/discovery.ts @@ -3,6 +3,7 @@ import { homedir } from "os" import { basename, join } from "path" import { fileURLToPath } from "url" import { log } from "../../shared/logger" +import { shouldLoadPluginForCwd } from "./scope-filter" import type { InstalledPluginsDatabase, InstalledPluginEntryV3, @@ -132,6 +133,7 @@ function v3EntryToInstallation(entry: InstalledPluginEntryV3): PluginInstallatio installedAt: entry.lastUpdated, lastUpdated: entry.lastUpdated, gitCommitSha: entry.gitCommitSha, + projectPath: entry.projectPath, } } @@ -177,6 +179,7 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL const settingsEnabledPlugins = settings?.enabledPlugins const overrideEnabledPlugins = options?.enabledPluginsOverride const pluginManifestLoader = options?.loadPluginManifestOverride ?? loadPluginManifest + const cwd = process.cwd() for (const [pluginKey, installation] of extractPluginEntries(db)) { if (!installation) continue @@ -186,6 +189,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL continue } + if (!shouldLoadPluginForCwd(installation, cwd)) { + log(`Skipping ${installation.scope}-scoped plugin outside current cwd: ${pluginKey}`, { + projectPath: installation.projectPath, + cwd, + }) + continue + } + const { installPath, scope, version } = installation if (!existsSync(installPath)) { diff --git a/src/features/claude-code-plugin-loader/scope-filter.test.ts b/src/features/claude-code-plugin-loader/scope-filter.test.ts new file mode 100644 index 000000000..3ac585e3d --- /dev/null +++ b/src/features/claude-code-plugin-loader/scope-filter.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { shouldLoadPluginForCwd } from "./scope-filter" + +const temporaryDirectories: string[] = [] + +function createTemporaryDirectory(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)) + temporaryDirectories.push(directory) + return directory +} + +describe("shouldLoadPluginForCwd", () => { + afterEach(() => { + mock.restore() + + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + describe("#given a user-scoped plugin", () => { + it("#when called with any cwd #then it loads", () => { + //#given + const installation = { scope: "user" as const } + + //#when + const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere") + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given a managed-scoped plugin", () => { + it("#when called with any cwd #then it loads", () => { + //#given + const installation = { scope: "managed" as const } + + //#when + const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere") + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given a project-scoped plugin without projectPath", () => { + it("#when called with any cwd #then it is skipped", () => { + //#given + const installation = { scope: "project" as const } + + //#when + const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given a local-scoped plugin without projectPath", () => { + it("#when called with any cwd #then it is skipped", () => { + //#given + const installation = { scope: "local" as const } + + //#when + const result = shouldLoadPluginForCwd(installation, "/tmp/anywhere") + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given a project-scoped plugin with matching projectPath", () => { + it("#when cwd exactly matches projectPath #then it loads", () => { + //#given + const projectDirectory = createTemporaryDirectory("omo-scope-") + const installation = { + scope: "project" as const, + projectPath: projectDirectory, + } + + //#when + const result = shouldLoadPluginForCwd(installation, projectDirectory) + + //#then + expect(result).toBe(true) + }) + + it("#when cwd is a subdirectory of projectPath #then it loads", () => { + //#given + const projectDirectory = createTemporaryDirectory("omo-scope-") + const installation = { + scope: "project" as const, + projectPath: projectDirectory, + } + + //#when + const result = shouldLoadPluginForCwd(installation, join(projectDirectory, "packages", "app")) + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given a project-scoped plugin with non-matching projectPath", () => { + it("#when cwd is unrelated #then it is skipped", () => { + //#given + const projectDirectory = createTemporaryDirectory("omo-scope-") + const otherDirectory = createTemporaryDirectory("omo-other-") + const installation = { + scope: "project" as const, + projectPath: projectDirectory, + } + + //#when + const result = shouldLoadPluginForCwd(installation, otherDirectory) + + //#then + expect(result).toBe(false) + }) + + it("#when cwd is the parent of projectPath #then it is skipped", () => { + //#given + const projectDirectory = createTemporaryDirectory("omo-scope-") + const installation = { + scope: "project" as const, + projectPath: join(projectDirectory, "nested"), + } + + //#when + const result = shouldLoadPluginForCwd(installation, projectDirectory) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given a local-scoped plugin with matching projectPath", () => { + it("#when cwd matches projectPath #then it loads", () => { + //#given + const projectDirectory = createTemporaryDirectory("omo-scope-") + const installation = { + scope: "local" as const, + projectPath: projectDirectory, + } + + //#when + const result = shouldLoadPluginForCwd(installation, projectDirectory) + + //#then + expect(result).toBe(true) + }) + }) + + describe("#given a local-scoped plugin with non-matching projectPath", () => { + it("#when cwd is unrelated #then it is skipped", () => { + //#given + const projectDirectory = createTemporaryDirectory("omo-scope-") + const otherDirectory = createTemporaryDirectory("omo-other-") + const installation = { + scope: "local" as const, + projectPath: projectDirectory, + } + + //#when + const result = shouldLoadPluginForCwd(installation, otherDirectory) + + //#then + expect(result).toBe(false) + }) + }) + + describe("#given a project-scoped plugin with a tilde-prefixed projectPath", () => { + let fakeHome: string + + beforeEach(() => { + fakeHome = createTemporaryDirectory("omo-home-") + mock.module("node:os", () => ({ + homedir: () => fakeHome, + tmpdir, + })) + mock.module("os", () => ({ + homedir: () => fakeHome, + tmpdir, + })) + }) + + it("#when the expanded home matches cwd #then it loads", async () => { + //#given + const { shouldLoadPluginForCwd: freshShouldLoad } = await import( + `./scope-filter?t=${Date.now()}-tilde-match` + ) + const installation = { + scope: "project" as const, + projectPath: "~/workspace/proj-a", + } + const cwd = join(fakeHome, "workspace", "proj-a") + + //#when + const result = freshShouldLoad(installation, cwd) + + //#then + expect(result).toBe(true) + }) + + it("#when the expanded home does not match cwd #then it is skipped", async () => { + //#given + const { shouldLoadPluginForCwd: freshShouldLoad } = await import( + `./scope-filter?t=${Date.now()}-tilde-mismatch` + ) + const installation = { + scope: "project" as const, + projectPath: "~/workspace/proj-a", + } + const cwd = join(fakeHome, "workspace", "proj-b") + + //#when + const result = freshShouldLoad(installation, cwd) + + //#then + expect(result).toBe(false) + }) + + it("#when projectPath is exactly ~ and cwd equals fake home #then it loads", async () => { + //#given + const { shouldLoadPluginForCwd: freshShouldLoad } = await import( + `./scope-filter?t=${Date.now()}-tilde-root` + ) + const installation = { + scope: "project" as const, + projectPath: "~", + } + + //#when + const result = freshShouldLoad(installation, fakeHome) + + //#then + expect(result).toBe(true) + }) + }) +}) diff --git a/src/features/claude-code-plugin-loader/scope-filter.ts b/src/features/claude-code-plugin-loader/scope-filter.ts new file mode 100644 index 000000000..b3651b5c5 --- /dev/null +++ b/src/features/claude-code-plugin-loader/scope-filter.ts @@ -0,0 +1,29 @@ +import { homedir } from "os" +import { join } from "path" +import { containsPath } from "../../shared/contains-path" +import type { PluginInstallation } from "./types" + +function expandTilde(inputPath: string): string { + if (inputPath === "~") { + return homedir() + } + if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { + return join(homedir(), inputPath.slice(2)) + } + return inputPath +} + +export function shouldLoadPluginForCwd( + installation: Pick, + cwd: string = process.cwd(), +): boolean { + if (installation.scope !== "project" && installation.scope !== "local") { + return true + } + + if (!installation.projectPath) { + return false + } + + return containsPath(expandTilde(installation.projectPath), cwd) +} diff --git a/src/features/claude-code-plugin-loader/types.ts b/src/features/claude-code-plugin-loader/types.ts index d93d6979b..1db4dd16f 100644 --- a/src/features/claude-code-plugin-loader/types.ts +++ b/src/features/claude-code-plugin-loader/types.ts @@ -18,6 +18,12 @@ export interface PluginInstallation { lastUpdated: string gitCommitSha?: string isLocal?: boolean + /** + * Claude Code records this on project/local-scoped installations. + * Absolute path (or `~`-prefixed) of the project the plugin was installed for. + * Used to filter project/local plugins that do not belong to the current cwd. + */ + projectPath?: string } /** @@ -51,6 +57,11 @@ export interface InstalledPluginEntryV3 { installPath: string lastUpdated: string gitCommitSha?: string + /** + * Claude Code records this on project/local-scoped installations. + * Absolute path (or `~`-prefixed) of the project the plugin was installed for. + */ + projectPath?: string } /** From cd95172e4273bb102c9189d934a9fd7c2073af7e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 15:55:58 +0900 Subject: [PATCH 45/86] fix(start-work): keep native command agents on config keys --- src/hooks/start-work/index.test.ts | 19 ++++++------ src/hooks/start-work/start-work-hook.ts | 10 +------ .../command-config-handler.test.ts | 29 +++++++++++++++++-- src/plugin-handlers/command-config-handler.ts | 4 +-- src/plugin-interface.test.ts | 2 +- src/plugin/chat-message.test.ts | 4 +-- 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index e51973a4e..c2a4fb09a 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -7,7 +7,6 @@ import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" import { createStartWorkHook } from "./index" import { createAtlasHook } from "../atlas" -import { getAgentDisplayName, getAgentListDisplayName } from "../../shared/agent-display-names" import { writeBoulderState, clearBoulderState, @@ -467,7 +466,7 @@ You are starting a Sisyphus work session. updateSpy.mockRestore() }) - test("should stamp the outgoing message with Atlas list key so follow-up events keep the handoff", async () => { + test("should stamp the outgoing message with Atlas config key so OpenCode can resolve the agent", async () => { // given const hook = createStartWorkHook(createMockPluginInput()) const output = { @@ -481,8 +480,8 @@ You are starting a Sisyphus work session. output ) - // then - expect(output.message.agent).toBe(getAgentDisplayName("atlas")) + // then - config key, not display name (matches no-sisyphus-gpt / boulder-continuation-injector convention) + expect(output.message.agent).toBe("atlas") }) test("should switch to Atlas even when current session is Sisyphus (regression: #3155)", async () => { @@ -502,7 +501,7 @@ You are starting a Sisyphus work session. ) // atlas is registered in beforeEach, so it must be selected - expect(output.message.agent).toBe(getAgentDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(sessionState.getSessionAgent("ses-sisyphus-to-atlas")).toBe("atlas") }) @@ -525,7 +524,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("Sisyphus - Ultraworker") + expect(output.message.agent).toBe("sisyphus") expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus") }) @@ -553,7 +552,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("Sisyphus - Ultraworker") + expect(output.message.agent).toBe("sisyphus") expect(sessionState.getSessionAgent("ses-prometheus-to-worker")).toBe("sisyphus") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) @@ -588,7 +587,7 @@ You are starting a Sisyphus work session. ) // then - expect(output.message.agent).toBe("Sisyphus - Ultraworker") + expect(output.message.agent).toBe("sisyphus") expect(readBoulderState(testDir)?.agent).toBe("sisyphus") }) @@ -623,7 +622,7 @@ You are starting a Sisyphus work session. await atlasHook.handler({ event: { type: "session.idle", properties: { sessionID: "session-123" } } }) // then - expect(output.message.agent).toBe(getAgentDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) @@ -713,7 +712,7 @@ You are starting a Sisyphus work session. await firePendingTimers() // then - expect(output.message.agent).toBe(getAgentDisplayName("atlas")) + expect(output.message.agent).toBe("atlas") expect(readBoulderState(testDir)?.session_ids).toContain("session-123") expect(readBoulderState(testDir)?.agent).toBe("atlas") expect(promptAsyncMock).toHaveBeenCalledTimes(1) diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index 7a85491df..b8ad853aa 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -11,11 +11,6 @@ import { clearBoulderState, } from "../../features/boulder-state" import { log } from "../../shared/logger" -import { - getAgentDisplayName, - getAgentListDisplayName, - stripAgentListSortPrefix, -} from "../../shared/agent-display-names" import { isAgentRegistered, updateSessionAgent, @@ -86,12 +81,9 @@ export function createStartWorkHook(ctx: PluginInput) { const activeAgent = isAgentRegistered("atlas") ? "atlas" : "sisyphus" - const activeAgentDisplayName = activeAgent === "atlas" - ? getAgentListDisplayName(activeAgent) - : getAgentDisplayName(activeAgent) updateSessionAgent(input.sessionID, activeAgent) if (output.message) { - output.message["agent"] = stripAgentListSortPrefix(activeAgentDisplayName) + output.message["agent"] = activeAgent } const existingState = readBoulderState(ctx.directory) diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 19f31f1e3..0b95395b2 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -97,7 +97,7 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); - test("remaps Atlas command agents to the list display name used by runtime agent lookup", async () => { + test("normalizes Atlas command agents to the config key OpenCode expects for native routing", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -119,6 +119,31 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe("atlas"); + }); + + test("normalizes legacy display-name command agents back to config keys", async () => { + // given + loadBuiltinCommandsSpy.mockReturnValue({ + "start-work": { + name: "start-work", + description: "(builtin) Start work", + template: "template", + agent: getAgentDisplayName("atlas"), + }, + }); + const config: Record = { command: {} }; + + // when + await applyCommandConfig({ + config, + pluginConfig: createPluginConfig(), + ctx: { directory: "/tmp" }, + pluginComponents: createPluginComponents(), + }); + + // then + const commandConfig = config.command as Record; + expect(commandConfig["start-work"]?.agent).toBe("atlas"); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index f45ff6531..5cb129291 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,5 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentDisplayName } from "../shared/agent-display-names"; +import { getAgentConfigKey } from "../shared/agent-display-names"; import { loadUserCommands, loadProjectCommands, @@ -96,7 +96,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentDisplayName(cmd.agent); + cmd.agent = getAgentConfigKey(cmd.agent); } } } diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts index 86f509de1..4dac3f7be 100644 --- a/src/plugin-interface.test.ts +++ b/src/plugin-interface.test.ts @@ -165,7 +165,7 @@ describe("createPluginInterface - command.execute.before", () => { ) // then - expect(output.message.agent).toBe("Atlas - Plan Executor") + expect(output.message.agent).toBe("atlas") expect(getSessionAgent("ses-command-atlas")).toBe("atlas") expect(readBoulderState(testDir)?.agent).toBe("atlas") }) diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index 4c2757d23..6dd7a0397 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -87,7 +87,7 @@ describe("createChatMessageHandler - /start-work integration", () => { await handler(input, output) // then - expect(output.message["agent"]).toBe("Sisyphus - Ultraworker") + expect(output.message["agent"]).toBe("sisyphus") expect(output.parts[0].text).toContain("") expect(output.parts[0].text).toContain("Auto-Selected Plan") expect(output.parts[0].text).toContain("boulder.json has been created") @@ -116,7 +116,7 @@ describe("createChatMessageHandler - /start-work integration", () => { await handler(input, output) // then - expect(output.message["agent"]).toBe("Sisyphus - Ultraworker") + expect(output.message["agent"]).toBe("sisyphus") expect(output.parts[0].text).toContain("") expect(output.parts[0].text).toContain("Auto-Selected Plan") expect(output.parts[0].text).toContain("my-feature-plan") From 43941296678542369eff9613882555d6261ca204 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 15:56:02 +0900 Subject: [PATCH 46/86] fix(compaction): harden continuation directive markers --- src/shared/internal-initiator-marker.test.ts | 119 +++++++++++++++++++ src/shared/internal-initiator-marker.ts | 9 +- src/shared/system-directive.test.ts | 44 +++++++ src/shared/system-directive.ts | 9 +- 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 src/shared/internal-initiator-marker.test.ts diff --git a/src/shared/internal-initiator-marker.test.ts b/src/shared/internal-initiator-marker.test.ts new file mode 100644 index 000000000..cc1035dd8 --- /dev/null +++ b/src/shared/internal-initiator-marker.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { + OMO_INTERNAL_INITIATOR_MARKER, + createInternalAgentTextPart, + stripInternalInitiatorMarkers, +} from "./internal-initiator-marker" + +describe("internal-initiator-marker", () => { + describe("createInternalAgentTextPart", () => { + test("#given clean text #when creating an internal agent text part #then appends exactly one marker", () => { + // given + const text = "Hello world" + + // when + const part = createInternalAgentTextPart(text) + + // then + expect(part.type).toBe("text") + expect(part.text).toBe(`Hello world\n${OMO_INTERNAL_INITIATOR_MARKER}`) + }) + + test("#given text already ending with the marker #when creating a text part #then does not duplicate the marker", () => { + // given + const text = `Already marked\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const part = createInternalAgentTextPart(text) + + // then + const markerCount = part.text.split(OMO_INTERNAL_INITIATOR_MARKER).length - 1 + expect(markerCount).toBe(1) + expect(part.text).toBe(`Already marked\n${OMO_INTERNAL_INITIATOR_MARKER}`) + }) + + test("#given text containing multiple embedded markers #when creating a text part #then collapses to a single trailing marker", () => { + // given + const text = `First\n${OMO_INTERNAL_INITIATOR_MARKER}\nSecond\n${OMO_INTERNAL_INITIATOR_MARKER}\nThird\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const part = createInternalAgentTextPart(text) + + // then + const markerCount = part.text.split(OMO_INTERNAL_INITIATOR_MARKER).length - 1 + expect(markerCount).toBe(1) + expect(part.text.endsWith(OMO_INTERNAL_INITIATOR_MARKER)).toBe(true) + }) + + test("#given text with embedded markers between content #when creating a text part #then strips embedded markers and keeps content", () => { + // given + const text = `Line one\n${OMO_INTERNAL_INITIATOR_MARKER}\nLine two\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const part = createInternalAgentTextPart(text) + + // then + expect(part.text).toContain("Line one") + expect(part.text).toContain("Line two") + const markerCount = part.text.split(OMO_INTERNAL_INITIATOR_MARKER).length - 1 + expect(markerCount).toBe(1) + }) + + test("#given empty text #when creating a text part #then still appends a single marker", () => { + // given + const text = "" + + // when + const part = createInternalAgentTextPart(text) + + // then + expect(part.text).toBe(`\n${OMO_INTERNAL_INITIATOR_MARKER}`) + }) + }) + + describe("stripInternalInitiatorMarkers", () => { + test("#given text with no markers #when stripping #then returns text trimmed at the end", () => { + // given + const text = "No markers here" + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("No markers here") + }) + + test("#given text with one trailing marker #when stripping #then removes the marker", () => { + // given + const text = `Content\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("Content") + }) + + test("#given text with multiple stacked markers #when stripping #then removes all of them", () => { + // given + const text = `Content\n${OMO_INTERNAL_INITIATOR_MARKER}\n${OMO_INTERNAL_INITIATOR_MARKER}\n${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("Content") + }) + + test("#given text with markers on consecutive lines without separators #when stripping #then removes all markers", () => { + // given + const text = `${OMO_INTERNAL_INITIATOR_MARKER}${OMO_INTERNAL_INITIATOR_MARKER}${OMO_INTERNAL_INITIATOR_MARKER}` + + // when + const result = stripInternalInitiatorMarkers(text) + + // then + expect(result).toBe("") + }) + }) +}) diff --git a/src/shared/internal-initiator-marker.ts b/src/shared/internal-initiator-marker.ts index 3e19c5819..7e810a15e 100644 --- a/src/shared/internal-initiator-marker.ts +++ b/src/shared/internal-initiator-marker.ts @@ -1,11 +1,18 @@ export const OMO_INTERNAL_INITIATOR_MARKER = "" +const INTERNAL_INITIATOR_MARKER_PATTERN = /\n*\s*/g + +export function stripInternalInitiatorMarkers(text: string): string { + return text.replace(INTERNAL_INITIATOR_MARKER_PATTERN, "").trimEnd() +} + export function createInternalAgentTextPart(text: string): { type: "text" text: string } { + const cleanText = stripInternalInitiatorMarkers(text) return { type: "text", - text: `${text}\n${OMO_INTERNAL_INITIATOR_MARKER}`, + text: `${cleanText}\n${OMO_INTERNAL_INITIATOR_MARKER}`, } } diff --git a/src/shared/system-directive.test.ts b/src/shared/system-directive.test.ts index 9da4c9563..2626bb771 100644 --- a/src/shared/system-directive.test.ts +++ b/src/shared/system-directive.test.ts @@ -144,6 +144,50 @@ const x = 1; const directive = ` ${createSystemDirective("TEST")}` expect(isSystemDirective(directive)).toBe(true) }) + + test("#given a ralph-loop ULW continuation prefixed with 'ultrawork ' #when checking system directive #then returns true", () => { + // given + const directive = `ultrawork ${createSystemDirective("RALPH LOOP 2/500")}\n\nYour previous attempt did not output the completion promise.` + + // when + const result = isSystemDirective(directive) + + // then + expect(result).toBe(true) + }) + + test("#given a continuation prefixed with 'ulw ' shorthand #when checking system directive #then returns true", () => { + // given + const directive = `ulw ${createSystemDirective("ULTRAWORK LOOP VERIFICATION 1/500")}\n\nYou already emitted DONE.` + + // when + const result = isSystemDirective(directive) + + // then + expect(result).toBe(true) + }) + + test("#given a continuation prefixed with uppercase 'ULTRAWORK ' #when checking system directive #then returns true", () => { + // given + const directive = `ULTRAWORK ${createSystemDirective("RALPH LOOP 5/500")}` + + // when + const result = isSystemDirective(directive) + + // then + expect(result).toBe(true) + }) + + test("#given user text that legitimately starts with 'ultrawork' word #when no directive follows #then returns false", () => { + // given + const text = "ultrawork is a great mode but I have a question about it" + + // when + const result = isSystemDirective(text) + + // then + expect(result).toBe(false) + }) }) describe("integration with keyword detection", () => { diff --git a/src/shared/system-directive.ts b/src/shared/system-directive.ts index f2ae8c602..001017aa5 100644 --- a/src/shared/system-directive.ts +++ b/src/shared/system-directive.ts @@ -7,6 +7,8 @@ export const SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE" +const SYSTEM_DIRECTIVE_LEADING_KEYWORD_PATTERN = /^\s*(?:ultrawork|ulw)\s+/i + /** * Creates a system directive header with the given type. * @param type - The directive type (e.g., "TODO CONTINUATION", "RALPH LOOP") @@ -23,7 +25,12 @@ export function createSystemDirective(type: string): string { * @returns true if the message is a system directive */ export function isSystemDirective(text: string): boolean { - return text.trimStart().startsWith(SYSTEM_DIRECTIVE_PREFIX) + const trimmed = text.trimStart() + if (trimmed.startsWith(SYSTEM_DIRECTIVE_PREFIX)) { + return true + } + const withoutLeadingKeyword = trimmed.replace(SYSTEM_DIRECTIVE_LEADING_KEYWORD_PATTERN, "") + return withoutLeadingKeyword.startsWith(SYSTEM_DIRECTIVE_PREFIX) } /** From 8925ec3a16d439277bec235be59618730acede18 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 16:00:47 +0900 Subject: [PATCH 47/86] fix(start-work): align command routing with exported agent keys --- .../command-config-handler.test.ts | 10 ++--- src/plugin-handlers/command-config-handler.ts | 7 ++- src/plugin-handlers/config-handler.test.ts | 45 +++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 0b95395b2..5af7f10d6 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,7 +5,7 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; -import { getAgentDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -97,7 +97,7 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); - test("normalizes Atlas command agents to the config key OpenCode expects for native routing", async () => { + test("normalizes Atlas command agents to the exported agent key used for native routing", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -119,10 +119,10 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe("atlas"); + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); - test("normalizes legacy display-name command agents back to config keys", async () => { + test("normalizes legacy display-name command agents to the exported agent key", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -144,6 +144,6 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe("atlas"); + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 5cb129291..471e4df52 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,5 +1,8 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentConfigKey } from "../shared/agent-display-names"; +import { + getAgentConfigKey, + getAgentListDisplayName, +} from "../shared/agent-display-names"; import { loadUserCommands, loadProjectCommands, @@ -96,7 +99,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentConfigKey(cmd.agent); + cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent)); } } } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 2257c45b9..3d322a675 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1250,6 +1250,51 @@ describe("config-handler plugin loading error boundary (#1559)", () => { }) }) +describe("command agent routing coherence", () => { + test("keeps start-work aligned with the exported Atlas agent key", async () => { + //#given + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mockResolvedValue: (value: Record) => void + } + createBuiltinAgentsMock.mockResolvedValue({ + sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" }, + atlas: { name: "atlas", prompt: "test", mode: "primary" }, + }) + ;(builtinCommands.loadBuiltinCommands as unknown as { + mockReturnValue: (value: Record) => void + }).mockReturnValue({ + "start-work": { + name: "start-work", + description: "(builtin) Start work", + template: "template", + agent: "atlas", + }, + }) + const pluginConfig = createPluginConfig({}) + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + //#when + await handler(config) + + //#then + const agentConfig = config.agent as Record + const commandConfig = config.command as Record + expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas")) + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")) + }) +}) + describe("per-agent todowrite/todoread deny when task_system enabled", () => { const AGENTS_WITH_TODO_DENY = new Set([ getAgentListDisplayName("sisyphus"), From 24629643f01bcd81480b3dc296f2687f90650072 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 16:09:09 +0900 Subject: [PATCH 48/86] fix(start-work): use canonical display name for command routing --- src/plugin-handlers/command-config-handler.test.ts | 10 +++++----- src/plugin-handlers/command-config-handler.ts | 4 ++-- src/plugin-handlers/config-handler.test.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 5af7f10d6..74267b069 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,7 +5,7 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -97,7 +97,7 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); - test("normalizes Atlas command agents to the exported agent key used for native routing", async () => { + test("normalizes Atlas command agents to the canonical display name used for native routing", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -119,10 +119,10 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); }); - test("normalizes legacy display-name command agents to the exported agent key", async () => { + test("normalizes legacy display-name command agents to the canonical display name", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -144,6 +144,6 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 471e4df52..86fdcfe26 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,7 +1,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { getAgentConfigKey, - getAgentListDisplayName, + getAgentDisplayName, } from "../shared/agent-display-names"; import { loadUserCommands, @@ -99,7 +99,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent)); + cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent)); } } } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 3d322a675..76e468f51 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1251,7 +1251,7 @@ describe("config-handler plugin loading error boundary (#1559)", () => { }) describe("command agent routing coherence", () => { - test("keeps start-work aligned with the exported Atlas agent key", async () => { + test("keeps start-work aligned with the canonical Atlas display name", async () => { //#given const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { mockResolvedValue: (value: Record) => void @@ -1291,7 +1291,7 @@ describe("command agent routing coherence", () => { const agentConfig = config.agent as Record const commandConfig = config.command as Record expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas")) - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")) + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")) }) }) From 06b825dd74a41bc610da86418c73eeee626a53f1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 16:18:26 +0900 Subject: [PATCH 49/86] fix(start-work): reuse registered opencode agent names --- .../claude-code-session-state/state.test.ts | 10 ++++++++++ src/features/claude-code-session-state/state.ts | 17 +++++++++++++++++ .../atlas/boulder-continuation-injector.ts | 9 +++++++-- .../compaction-context-injector/recovery.ts | 8 ++++++-- src/hooks/no-hephaestus-non-gpt/hook.ts | 14 ++++++++------ src/hooks/no-sisyphus-gpt/hook.ts | 14 ++++++++------ src/hooks/runtime-fallback/auto-retry.ts | 6 +++--- src/hooks/start-work/start-work-hook.ts | 3 ++- .../continuation-injection.test.ts | 4 ++-- .../continuation-injection.ts | 10 +++++++--- .../command-config-handler.test.ts | 13 ++++++++----- src/plugin-handlers/command-config-handler.ts | 4 ++-- src/plugin-handlers/config-handler.test.ts | 4 ++-- 13 files changed, 82 insertions(+), 34 deletions(-) diff --git a/src/features/claude-code-session-state/state.test.ts b/src/features/claude-code-session-state/state.test.ts index 367ad6d3e..69c482b40 100644 --- a/src/features/claude-code-session-state/state.test.ts +++ b/src/features/claude-code-session-state/state.test.ts @@ -10,6 +10,7 @@ import { getMainSessionID, registerAgentName, isAgentRegistered, + resolveRegisteredAgentName, _resetForTesting, } from "./state" @@ -140,6 +141,15 @@ describe("claude-code-session-state", () => { expect(isAgentRegistered("Atlas - Plan Executor")).toBe(true) }) + test("should resolve config keys back to the registered raw agent name", () => { + // given + registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + + // when / then + expect(resolveRegisteredAgentName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + expect(resolveRegisteredAgentName("Atlas - Plan Executor")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") + }) + describe("#given atlas display name with zero-width prefix", () => { describe("#when checking registration without the zero-width prefix", () => { test("#then it treats the display name as registered", () => { diff --git a/src/features/claude-code-session-state/state.ts b/src/features/claude-code-session-state/state.ts index f044b4ec6..496d655fd 100644 --- a/src/features/claude-code-session-state/state.ts +++ b/src/features/claude-code-session-state/state.ts @@ -14,6 +14,7 @@ export function getMainSessionID(): string | undefined { } const registeredAgentNames = new Set() +const registeredAgentAliases = new Map() const ZERO_WIDTH_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g @@ -28,10 +29,16 @@ function normalizeStoredAgentName(name: string): string { export function registerAgentName(name: string): void { const normalizedName = normalizeRegisteredAgentName(name) registeredAgentNames.add(normalizedName) + if (!registeredAgentAliases.has(normalizedName)) { + registeredAgentAliases.set(normalizedName, name) + } const configKey = normalizeRegisteredAgentName(getAgentConfigKey(name)) if (configKey !== normalizedName) { registeredAgentNames.add(configKey) + if (!registeredAgentAliases.has(configKey)) { + registeredAgentAliases.set(configKey, name) + } } } @@ -39,6 +46,15 @@ export function isAgentRegistered(name: string): boolean { return registeredAgentNames.has(normalizeRegisteredAgentName(name)) } +export function resolveRegisteredAgentName(name: string | undefined): string | undefined { + if (typeof name !== "string") { + return undefined + } + + const normalizedName = normalizeRegisteredAgentName(name) + return registeredAgentAliases.get(normalizedName) ?? normalizeStoredAgentName(name) +} + /** @internal For testing only */ export function _resetForTesting(): void { _mainSessionID = undefined @@ -46,6 +62,7 @@ export function _resetForTesting(): void { syncSubagentSessions.clear() sessionAgentMap.clear() registeredAgentNames.clear() + registeredAgentAliases.clear() } const sessionAgentMap = new Map() diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index ca4ee146e..8f3e1a57d 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -1,6 +1,9 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { isAgentRegistered } from "../../features/claude-code-session-state" +import { + isAgentRegistered, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { HOOK_NAME } from "./hook-name" @@ -55,7 +58,9 @@ export async function injectBoulderContinuation(input: { `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + preferredSessionContext + worktreeContext - const continuationAgent = (agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined))?.replace(/\u200B/g, "") + const continuationAgent = resolveRegisteredAgentName( + agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined), + ) if (!continuationAgent || !isAgentRegistered(continuationAgent)) { log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, { diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index 35b8a89de..31040d35f 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -1,4 +1,7 @@ -import { updateSessionAgent } from "../../features/claude-code-session-state" +import { + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { getCompactionAgentConfigCheckpoint, } from "../../shared/compaction-agent-config-checkpoint" @@ -66,6 +69,7 @@ export function createRecoveryLogic( checkpointWithAgent, currentPromptConfig, ) + const launchAgent = resolveRegisteredAgentName(expectedPromptConfig.agent) const model = expectedPromptConfig.model const tools = expectedPromptConfig.tools @@ -81,7 +85,7 @@ export function createRecoveryLogic( path: { id: sessionID }, body: { noReply: true, - agent: expectedPromptConfig.agent, + agent: launchAgent ?? expectedPromptConfig.agent, ...(model ? { model } : {}), ...(tools ? { tools } : {}), parts: [createInternalAgentTextPart(AGENT_RECOVERY_PROMPT)], diff --git a/src/hooks/no-hephaestus-non-gpt/hook.ts b/src/hooks/no-hephaestus-non-gpt/hook.ts index afce7ba9c..66efed424 100644 --- a/src/hooks/no-hephaestus-non-gpt/hook.ts +++ b/src/hooks/no-hephaestus-non-gpt/hook.ts @@ -1,8 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { isGptModel } from "../../agents/types" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { log } from "../../shared" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentConfigKey } from "../../shared/agent-display-names" const TOAST_TITLE = "NEVER Use Hephaestus with Non-GPT" const TOAST_MESSAGE = [ @@ -10,8 +14,6 @@ const TOAST_MESSAGE = [ "Hephaestus is trash without GPT.", "For Claude/Kimi/GLM models, always use Sisyphus.", ].join("\n") -const SISYPHUS_DISPLAY = getAgentDisplayName("sisyphus") - type NoHephaestusNonGptHookOptions = { allowNonGptModel?: boolean } @@ -54,9 +56,9 @@ export function createNoHephaestusNonGptHook( if (allowNonGptModel) { return } - input.agent = "sisyphus" + input.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus" if (output?.message) { - output.message.agent = "sisyphus" + output.message.agent = resolveRegisteredAgentName("sisyphus") ?? "sisyphus" } updateSessionAgent(input.sessionID, "sisyphus") } diff --git a/src/hooks/no-sisyphus-gpt/hook.ts b/src/hooks/no-sisyphus-gpt/hook.ts index 65ab8d113..fa1b53ebd 100644 --- a/src/hooks/no-sisyphus-gpt/hook.ts +++ b/src/hooks/no-sisyphus-gpt/hook.ts @@ -1,8 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { isGptModel, isGpt5_4Model } from "../../agents/types" -import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, + updateSessionAgent, +} from "../../features/claude-code-session-state" import { log } from "../../shared" -import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-display-names" +import { getAgentConfigKey } from "../../shared/agent-display-names" const TOAST_TITLE = "NEVER Use Sisyphus with GPT" const TOAST_MESSAGE = [ @@ -10,8 +14,6 @@ const TOAST_MESSAGE = [ "Do NOT use Sisyphus with GPT (except GPT-5.4 which has specialized support).", "For GPT models (other than 5.4), always use Hephaestus.", ].join("\n") -const HEPHAESTUS_DISPLAY = getAgentDisplayName("hephaestus") - function showToast(ctx: PluginInput, sessionID: string): void { ctx.client.tui.showToast({ body: { @@ -43,9 +45,9 @@ export function createNoSisyphusGptHook(ctx: PluginInput) { if (agentKey === "sisyphus" && modelID && isGptModel(modelID) && !isGpt5_4Model(modelID)) { showToast(ctx, input.sessionID) - input.agent = "hephaestus" + input.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus" if (output?.message) { - output.message.agent = "hephaestus" + output.message.agent = resolveRegisteredAgentName("hephaestus") ?? "hephaestus" } updateSessionAgent(input.sessionID, "hephaestus") } diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index de946af5b..cbb3be2be 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -9,7 +9,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { buildRetryModelPayload } from "./retry-model-payload" import { getLastUserRetryParts } from "./last-user-retry-parts" import { extractSessionMessages } from "./session-messages" -import { getAgentDisplayName } from "../../shared/agent-display-names" +import { resolveRegisteredAgentName } from "../../features/claude-code-session-state" const SESSION_TTL_MS = 30 * 60 * 1000 @@ -133,14 +133,14 @@ export function createAutoRetryHelpers(deps: HookDeps) { }) const retryAgent = resolvedAgent ?? getSessionAgent(sessionID) + const launchAgent = resolveRegisteredAgentName(retryAgent) sessionAwaitingFallbackResult.add(sessionID) scheduleSessionFallbackTimeout(sessionID, retryAgent) await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - // Use config key to avoid HTTP header validation issues with display names - ...(retryAgent ? { agent: retryAgent } : {}), + ...(launchAgent ? { agent: launchAgent } : {}), ...retryModelPayload, parts: retryParts, }, diff --git a/src/hooks/start-work/start-work-hook.ts b/src/hooks/start-work/start-work-hook.ts index b8ad853aa..ec8a5011b 100644 --- a/src/hooks/start-work/start-work-hook.ts +++ b/src/hooks/start-work/start-work-hook.ts @@ -13,6 +13,7 @@ import { import { log } from "../../shared/logger" import { isAgentRegistered, + resolveRegisteredAgentName, updateSessionAgent, } from "../../features/claude-code-session-state" import { detectWorktreePath } from "./worktree-detector" @@ -83,7 +84,7 @@ export function createStartWorkHook(ctx: PluginInput) { : "sisyphus" updateSessionAgent(input.sessionID, activeAgent) if (output.message) { - output.message["agent"] = activeAgent + output.message["agent"] = resolveRegisteredAgentName(activeAgent) ?? activeAgent } const existingState = readBoulderState(ctx.directory) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index 514b6f15b..56dd7cb4e 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -5,7 +5,7 @@ import { injectContinuation } from "./continuation-injection" import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" describe("injectContinuation", () => { - test("normalizes built-in display names to config keys before promptAsync", async () => { + test("preserves the registered built-in agent name before promptAsync", async () => { // given let capturedAgent: string | undefined const ctx = { @@ -40,7 +40,7 @@ describe("injectContinuation", () => { }) // then - expect(capturedAgent).toBe("sisyphus") + expect(capturedAgent).toBe("Sisyphus - Ultraworker") }) test("inherits tools from resolved message info when reinjecting", async () => { diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index fdd12efc1..5844bebd2 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -1,7 +1,10 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { BackgroundManager } from "../../features/background-agent" -import { getSessionAgent } from "../../features/claude-code-session-state" +import { + getSessionAgent, + resolveRegisteredAgentName, +} from "../../features/claude-code-session-state" import { createInternalAgentTextPart, normalizeSDKResponse, @@ -127,6 +130,7 @@ export async function injectContinuation(args: { } const promptAgent = normalizeAgentForPromptKey(agentName) + const launchAgent = resolveRegisteredAgentName(agentName) if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) @@ -168,7 +172,7 @@ ${todoList}` try { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, - agent: promptAgent, + agent: launchAgent ?? promptAgent, model, incompleteCount: freshIncompleteCount, }) @@ -183,7 +187,7 @@ ${todoList}` await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - agent: promptAgent, + agent: launchAgent ?? promptAgent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 74267b069..41836dc6b 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -5,7 +5,10 @@ import * as skillLoader from "../features/opencode-skill-loader"; import type { OhMyOpenCodeConfig } from "../config"; import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; -import { getAgentDisplayName } from "../shared/agent-display-names"; +import { + getAgentDisplayName, + getAgentListDisplayName, +} from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { return { @@ -97,7 +100,7 @@ describe("applyCommandConfig", () => { expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill"); }); - test("normalizes Atlas command agents to the canonical display name used for native routing", async () => { + test("normalizes Atlas command agents to the exported list key used by opencode command routing", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -119,10 +122,10 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); - test("normalizes legacy display-name command agents to the canonical display name", async () => { + test("normalizes legacy display-name command agents to the exported list key", async () => { // given loadBuiltinCommandsSpy.mockReturnValue({ "start-work": { @@ -144,6 +147,6 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 86fdcfe26..471e4df52 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,7 +1,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { getAgentConfigKey, - getAgentDisplayName, + getAgentListDisplayName, } from "../shared/agent-display-names"; import { loadUserCommands, @@ -99,7 +99,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent)); + cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent)); } } } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 76e468f51..1d9324f9e 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -1251,7 +1251,7 @@ describe("config-handler plugin loading error boundary (#1559)", () => { }) describe("command agent routing coherence", () => { - test("keeps start-work aligned with the canonical Atlas display name", async () => { + test("keeps start-work aligned with the exported Atlas list key opencode matches exactly", async () => { //#given const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { mockResolvedValue: (value: Record) => void @@ -1291,7 +1291,7 @@ describe("command agent routing coherence", () => { const agentConfig = config.agent as Record const commandConfig = config.command as Record expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas")) - expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")) + expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")) }) }) From ed16dc06081b681a7b4aee75c535b46d14f13f47 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:14:17 +0900 Subject: [PATCH 50/86] fix(chat-params): complete maxOutputTokens migration in session prompt params --- src/features/background-agent/manager.test.ts | 2 +- src/features/background-agent/spawner.test.ts | 2 +- src/shared/session-prompt-params-helpers.ts | 2 +- src/shared/session-prompt-params-state.test.ts | 2 +- src/tools/call-omo-agent/sync-executor.test.ts | 2 +- src/tools/call-omo-agent/sync-executor.ts | 2 +- src/tools/delegate-task/sync-prompt-sender.test.ts | 4 ++-- src/tools/delegate-task/sync-prompt-sender.ts | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 9e10e3c57..4e0e00892 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1863,10 +1863,10 @@ describe("BackgroundManager.resume model persistence", () => { expect(getSessionPromptParams("session-advanced")).toEqual({ temperature: 0.25, topP: 0.55, + maxOutputTokens: 8192, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 8192, }, }) }) diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index d3896e55f..4c5ddeaf2 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -400,10 +400,10 @@ describe("background-agent spawner fallback model promotion", () => { expect(getSessionPromptParams("session-123")).toEqual({ temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) }) diff --git a/src/shared/session-prompt-params-helpers.ts b/src/shared/session-prompt-params-helpers.ts index 7ce24c826..f50707956 100644 --- a/src/shared/session-prompt-params-helpers.ts +++ b/src/shared/session-prompt-params-helpers.ts @@ -20,12 +20,12 @@ export function applySessionPromptParams( const promptOptions: Record = { ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}), ...(model.thinking ? { thinking: model.thinking } : {}), - ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), } setSessionPromptParams(sessionID, { ...(model.temperature !== undefined ? { temperature: model.temperature } : {}), ...(model.top_p !== undefined ? { topP: model.top_p } : {}), + ...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}), ...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}), }) } diff --git a/src/shared/session-prompt-params-state.test.ts b/src/shared/session-prompt-params-state.test.ts index b97a80565..d52670be6 100644 --- a/src/shared/session-prompt-params-state.test.ts +++ b/src/shared/session-prompt-params-state.test.ts @@ -18,9 +18,9 @@ describe("session-prompt-params-state", () => { const params = { temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", - maxTokens: 4096, }, } diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index baa59fb78..404e3fee0 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -190,10 +190,10 @@ describe("executeSync", () => { expect(promptInput?.body.temperature).toBe(0.12) expect(promptInput?.body.topP).toBe(0.34) expect(promptInput?.body.options).toEqual({ - maxTokens: 5678, reasoningEffort: "medium", thinking: { type: "disabled" }, }) + expect(promptInput?.body.maxOutputTokens).toBe(5678) }) test("records metadata with description and created session id", async () => { diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index 096a80216..f0f65d7e1 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -43,12 +43,12 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R const promptOptions: Record = { ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}), ...(model.thinking ? { thinking: model.thinking } : {}), - ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), } return { ...(model.temperature !== undefined ? { temperature: model.temperature } : {}), ...(model.top_p !== undefined ? { topP: model.top_p } : {}), + ...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}), ...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}), } } diff --git a/src/tools/delegate-task/sync-prompt-sender.test.ts b/src/tools/delegate-task/sync-prompt-sender.test.ts index 32970e72a..f86e87997 100644 --- a/src/tools/delegate-task/sync-prompt-sender.test.ts +++ b/src/tools/delegate-task/sync-prompt-sender.test.ts @@ -277,15 +277,15 @@ bunDescribe("sendSyncPrompt", () => { bunExpect(promptArgs.body.options).toEqual({ reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }) + bunExpect(promptArgs.body.maxOutputTokens).toBe(4096) bunExpect(getSessionPromptParams("test-session")).toEqual({ temperature: 0.4, topP: 0.7, + maxOutputTokens: 4096, options: { reasoningEffort: "high", thinking: { type: "disabled" }, - maxTokens: 4096, }, }) }) diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index 882258d98..bd38830e5 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -30,12 +30,12 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R const promptOptions: Record = { ...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}), ...(model.thinking ? { thinking: model.thinking } : {}), - ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), } return { ...(model.temperature !== undefined ? { temperature: model.temperature } : {}), ...(model.top_p !== undefined ? { topP: model.top_p } : {}), + ...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}), ...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}), } } From 1cf4119dd47af64242104955d593669cff7ddb9f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:15:02 +0900 Subject: [PATCH 51/86] fix(background): use parent session variant in notifyParentSession instead of child task variant --- src/features/background-agent/manager.test.ts | 65 ++++++++++++++++++- src/features/background-agent/manager.ts | 5 +- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 4e0e00892..0fcb463e3 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1059,7 +1059,18 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => { prompt: promptMock, promptAsync: promptMock, abort: async () => ({}), - messages: async () => ({ data: [] }), + messages: async () => ({ + data: [{ + info: { + agent: "explore", + model: { + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "high", + }, + }, + }], + }), }, } const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) @@ -1219,6 +1230,58 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => { manager.shutdown() }) + test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => { + //#given + const promptCalls: Array<{ body: Record }> = [] + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { path: { id: string }; body: Record }) => { + promptCalls.push({ body: args.body }) + return {} + }, + abort: async () => ({}), + messages: async () => ({ + data: [{ + info: { + agent: "explore", + model: { + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "max", + }, + }, + }], + }), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const task: BackgroundTask = { + id: "task-parent-variant-wins", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task with mismatched variant", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "high" }, + } + getPendingByParent(manager).set("session-parent", new Set([task.id])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) + .notifyParentSession(task) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0].body.variant).toBe("max") + + manager.shutdown() + }) + test("should not include variant in promptAsync body when task has no variant", async () => { //#given const promptCalls: Array<{ body: Record }> = [] diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 1a23c3569..d06d5fcaf 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1783,6 +1783,7 @@ export class BackgroundManager { let agent: string | undefined = task.parentAgent let model: { providerID: string; modelID: string } | undefined let tools: Record | undefined = task.parentTools + let promptContext: ReturnType = null if (this.enableParentSessionNotifications) { try { @@ -1796,7 +1797,7 @@ export class BackgroundManager { tools?: Record } }>) - const promptContext = resolvePromptContextFromSessionMessages( + promptContext = resolvePromptContextFromSessionMessages( messages, task.parentSessionID, ) @@ -1840,7 +1841,7 @@ export class BackgroundManager { const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt" const shouldReply = allComplete || isTaskFailure - const variant = task.model?.variant + const variant = promptContext?.model?.variant try { await this.client.session.promptAsync({ From 80c74c8849de1ce1b9ef9c8ed961cea5ce67fe09 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:15:18 +0900 Subject: [PATCH 52/86] fix(background): prevent double-decrement of descendant quota in processKey error cleanup --- src/features/background-agent/manager.test.ts | 44 +++++++++++++++++++ src/features/background-agent/manager.ts | 4 -- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 0fcb463e3..67b584d4b 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -218,6 +218,10 @@ function getRootDescendantCounts(manager: BackgroundManager): Map }).rootDescendantCounts } +function getPreStartDescendantReservations(manager: BackgroundManager): Set { + return (manager as unknown as { preStartDescendantReservations: Set }).preStartDescendantReservations +} + function getQueuesByKey( manager: BackgroundManager ): Map> { @@ -2526,6 +2530,46 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(retryTask.status).toBe("pending") }) + test("should only roll back the failed task reservation once when siblings still exist", async () => { + // given + const concurrencyKey = "test-agent" + const task = createMockTask({ + id: "task-single-reservation-rollback", + sessionID: "session-single-reservation-rollback", + parentSessionID: "session-root", + status: "pending", + agent: "test-agent", + rootSessionID: "session-root", + }) + delete (task as Partial).sessionID + + const input = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionID: task.parentSessionID, + parentMessageID: task.parentMessageID, + } + + getTaskMap(manager).set(task.id, task) + getQueuesByKey(manager).set(concurrencyKey, [{ task, input }]) + getRootDescendantCounts(manager).set("session-root", 2) + getPreStartDescendantReservations(manager).add(task.id) + stubNotifyParentSession(manager) + + ;(manager as unknown as { + startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise + }).startTask = async () => { + throw new Error("session create failed") + } + + // when + await processKeyForTest(manager, concurrencyKey) + + // then + expect(getRootDescendantCounts(manager).get("session-root")).toBe(1) + }) + test("should keep the next queued task when the first task is cancelled during session creation", async () => { // given const firstSessionID = "ses-first-cancelled-during-create" diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index d06d5fcaf..bd8ff2477 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -422,10 +422,6 @@ export class BackgroundManager { this.concurrencyManager.release(key) } - if (item.task.rootSessionID) { - this.unregisterRootDescendant(item.task.rootSessionID) - } - removeTaskToastTracking(item.task.id) // Abort the orphaned session if one was created before the error From 63ba16bcceba07f41c29f170c049bdeb4f3dd0f1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:16:28 +0900 Subject: [PATCH 53/86] fix(oauth): wire refresh mutex into provider.refresh() for concurrent deduplication Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../skill-mcp-manager/oauth-handler.test.ts | 145 ++++++++++++++++++ .../skill-mcp-manager/oauth-handler.ts | 19 ++- 2 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/features/skill-mcp-manager/oauth-handler.test.ts diff --git a/src/features/skill-mcp-manager/oauth-handler.test.ts b/src/features/skill-mcp-manager/oauth-handler.test.ts new file mode 100644 index 000000000..d6eb317bc --- /dev/null +++ b/src/features/skill-mcp-manager/oauth-handler.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import type { OAuthTokenData } from "../mcp-oauth/storage" +import type { OAuthProviderFactory, OAuthProviderLike } from "./types" + +mock.module("../mcp-oauth/provider", () => ({ + McpOAuthProvider: class MockMcpOAuthProvider {}, +})) + +type OAuthHandlerModule = typeof import("./oauth-handler") + +async function importFreshOAuthHandlerModule(): Promise { + return await import(new URL(`./oauth-handler.ts?oauth-handler-test=${Date.now()}-${Math.random()}`, import.meta.url).href) +} + +type Deferred = { + promise: Promise + resolve: (value: TValue) => void +} + +function createDeferred(): Deferred { + let resolvePromise: ((value: TValue) => void) | null = null + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + if (!resolvePromise) { + throw new Error("Failed to create deferred promise") + } + + return { promise, resolve: resolvePromise } +} + +function createConfig(serverUrl: string): ClaudeCodeMcpServer { + return { + url: serverUrl, + oauth: { + clientId: "test-client", + }, + } +} + +describe("oauth-handler refresh mutex wiring", () => { + beforeEach(() => { + mock.restore() + }) + + it("deduplicates concurrent pre-request refresh attempts for the same server", async () => { + // given + const { buildHttpRequestInit } = await importFreshOAuthHandlerModule() + const deferred = createDeferred() + const refresh = mock(() => deferred.promise) + const provider: OAuthProviderLike = { + tokens: () => ({ + accessToken: "expired-token", + refreshToken: "refresh-token", + expiresAt: Math.floor(Date.now() / 1000) - 60, + }), + login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)), + refresh, + } + const authProviders = new Map() + const createOAuthProvider: OAuthProviderFactory = () => provider + + // when + const firstRequest = buildHttpRequestInit(createConfig("https://same.example.com/mcp"), authProviders, createOAuthProvider) + const secondRequest = buildHttpRequestInit(createConfig("https://same.example.com/mcp"), authProviders, createOAuthProvider) + + // then + expect(refresh).toHaveBeenCalledTimes(1) + deferred.resolve({ accessToken: "refreshed-token" }) + await expect(firstRequest).resolves.toEqual({ headers: { Authorization: "Bearer refreshed-token" } }) + await expect(secondRequest).resolves.toEqual({ headers: { Authorization: "Bearer refreshed-token" } }) + }) + + it("allows different servers to refresh independently after request auth errors", async () => { + // given + const { handlePostRequestAuthError } = await importFreshOAuthHandlerModule() + const firstDeferred = createDeferred() + const secondDeferred = createDeferred() + const firstProvider: OAuthProviderLike = { + tokens: () => ({ accessToken: "expired-a", refreshToken: "refresh-a" }), + login: mock(async () => ({ accessToken: "login-a" } satisfies OAuthTokenData)), + refresh: mock(() => firstDeferred.promise), + } + const secondProvider: OAuthProviderLike = { + tokens: () => ({ accessToken: "expired-b", refreshToken: "refresh-b" }), + login: mock(async () => ({ accessToken: "login-b" } satisfies OAuthTokenData)), + refresh: mock(() => secondDeferred.promise), + } + const providers = new Map([ + ["https://server-a.example.com/mcp", firstProvider], + ["https://server-b.example.com/mcp", secondProvider], + ]) + + // when + const firstAttempt = handlePostRequestAuthError({ + error: new Error("401 Unauthorized"), + config: createConfig("https://server-a.example.com/mcp"), + authProviders: providers, + }) + const secondAttempt = handlePostRequestAuthError({ + error: new Error("403 Forbidden"), + config: createConfig("https://server-b.example.com/mcp"), + authProviders: providers, + }) + + // then + expect(firstProvider.refresh).toHaveBeenCalledTimes(1) + expect(secondProvider.refresh).toHaveBeenCalledTimes(1) + firstDeferred.resolve({ accessToken: "refreshed-a" }) + secondDeferred.resolve({ accessToken: "refreshed-b" }) + await expect(firstAttempt).resolves.toBe(true) + await expect(secondAttempt).resolves.toBe(true) + }) + + it("allows a new refresh after the previous same-server refresh completes", async () => { + // given + const { handlePostRequestAuthError } = await importFreshOAuthHandlerModule() + const refresh = mock(async () => ({ accessToken: `refreshed-${refresh.mock.calls.length + 1}` } satisfies OAuthTokenData)) + const provider: OAuthProviderLike = { + tokens: () => ({ accessToken: "expired-token", refreshToken: "refresh-token" }), + login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)), + refresh, + } + const authProviders = new Map([["https://same.example.com/mcp", provider]]) + + // when + const firstResult = await handlePostRequestAuthError({ + error: new Error("401 Unauthorized"), + config: createConfig("https://same.example.com/mcp"), + authProviders, + }) + const secondResult = await handlePostRequestAuthError({ + error: new Error("401 Unauthorized"), + config: createConfig("https://same.example.com/mcp"), + authProviders, + }) + + // then + expect(firstResult).toBe(true) + expect(secondResult).toBe(true) + expect(refresh).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/features/skill-mcp-manager/oauth-handler.ts b/src/features/skill-mcp-manager/oauth-handler.ts index d1b2b7513..63f3d8676 100644 --- a/src/features/skill-mcp-manager/oauth-handler.ts +++ b/src/features/skill-mcp-manager/oauth-handler.ts @@ -1,5 +1,6 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { McpOAuthProvider } from "../mcp-oauth/provider" +import { withRefreshMutex } from "../mcp-oauth/refresh-mutex" import type { OAuthTokenData } from "../mcp-oauth/storage" import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up" import type { OAuthProviderFactory, OAuthProviderLike } from "./types" @@ -52,14 +53,15 @@ export async function buildHttpRequestInit( } } - if (tokenData && isTokenExpired(tokenData)) { - try { - tokenData = tokenData.refreshToken - ? await provider.refresh(tokenData.refreshToken) - : await provider.login() - } catch { + if (tokenData && isTokenExpired(tokenData)) { try { - tokenData = await provider.login() + const refreshToken = tokenData.refreshToken + tokenData = refreshToken + ? await withRefreshMutex(config.url, () => provider.refresh(refreshToken)) + : await provider.login() + } catch { + try { + tokenData = await provider.login() } catch { tokenData = null } @@ -149,7 +151,8 @@ export async function handlePostRequestAuthError(params: { refreshAttempted.add(config.url) try { - await provider.refresh(tokenData.refreshToken) + const refreshToken = tokenData.refreshToken + await withRefreshMutex(config.url, () => provider.refresh(refreshToken)) return true } catch { return false From 0479693ca370e2e7507f6d1082d85c215eea4e77 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:16:36 +0900 Subject: [PATCH 54/86] fix(oauth): wire post-request 401/403 handler into skill-mcp withOperationRetry Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../manager-oauth-retry.test.ts | 162 ++++++++++++++++++ src/features/skill-mcp-manager/manager.ts | 14 +- 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/features/skill-mcp-manager/manager-oauth-retry.test.ts diff --git a/src/features/skill-mcp-manager/manager-oauth-retry.test.ts b/src/features/skill-mcp-manager/manager-oauth-retry.test.ts new file mode 100644 index 000000000..5d6dabd77 --- /dev/null +++ b/src/features/skill-mcp-manager/manager-oauth-retry.test.ts @@ -0,0 +1,162 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test" +import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" +import type { OAuthTokenData } from "../mcp-oauth/storage" +import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types" + +const mockGetOrCreateClient = mock(async () => { + throw new Error("not used") +}) + +const mockGetOrCreateClientWithRetryImpl = mock(async () => ({ + callTool: mock(async () => ({ content: [{ type: "text", text: "unused" }] })), + close: mock(async () => {}), +})) + +mock.module("./connection", () => ({ + getOrCreateClient: mockGetOrCreateClient, + getOrCreateClientWithRetryImpl: mockGetOrCreateClientWithRetryImpl, +})) + +mock.module("../mcp-oauth/provider", () => ({ + McpOAuthProvider: class MockMcpOAuthProvider {}, +})) + +type ManagerModule = typeof import("./manager") + +async function importFreshManagerModule(): Promise { + return await import(new URL(`./manager.ts?oauth-retry-test=${Date.now()}-${Math.random()}`, import.meta.url).href) +} + +function createInfo(): SkillMcpClientInfo { + return { + serverName: "oauth-server", + skillName: "oauth-skill", + sessionID: "session-1", + scope: "builtin", + } +} + +function createContext(): SkillMcpServerContext { + return { + skillName: "oauth-skill", + config: { + url: "https://mcp.example.com/mcp", + oauth: { clientId: "test-client" }, + } satisfies ClaudeCodeMcpServer, + } +} + +afterAll(() => { + mock.restore() +}) + +describe("SkillMcpManager post-request OAuth retry", () => { + beforeEach(() => { + mockGetOrCreateClient.mockClear() + mockGetOrCreateClientWithRetryImpl.mockClear() + }) + + it("retries the operation after a 401 refresh succeeds", async () => { + // given + const { SkillMcpManager } = await importFreshManagerModule() + const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData)) + const manager = new SkillMcpManager({ + createOAuthProvider: () => ({ + tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }), + login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)), + refresh, + }), + }) + const callTool = mock(async () => { + if (callTool.mock.calls.length === 1) { + throw new Error("401 Unauthorized") + } + + return { content: [{ type: "text", text: "success" }] } + }) + mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) }) + + // when + const result = await manager.callTool(createInfo(), createContext(), "test-tool", {}) + + // then + expect(result).toEqual([{ type: "text", text: "success" }]) + expect(refresh).toHaveBeenCalledTimes(1) + expect(callTool).toHaveBeenCalledTimes(2) + }) + + it("retries the operation after a 403 refresh succeeds without step-up scope", async () => { + // given + const { SkillMcpManager } = await importFreshManagerModule() + const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData)) + const manager = new SkillMcpManager({ + createOAuthProvider: () => ({ + tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }), + login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)), + refresh, + }), + }) + const callTool = mock(async () => { + if (callTool.mock.calls.length === 1) { + throw new Error("403 Forbidden") + } + + return { content: [{ type: "text", text: "success" }] } + }) + mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) }) + + // when + const result = await manager.callTool(createInfo(), createContext(), "test-tool", {}) + + // then + expect(result).toEqual([{ type: "text", text: "success" }]) + expect(refresh).toHaveBeenCalledTimes(1) + expect(callTool).toHaveBeenCalledTimes(2) + }) + + it("propagates the auth error without retry when refresh fails", async () => { + // given + const { SkillMcpManager } = await importFreshManagerModule() + const refresh = mock(async () => { + throw new Error("refresh failed") + }) + const manager = new SkillMcpManager({ + createOAuthProvider: () => ({ + tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }), + login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)), + refresh, + }), + }) + const callTool = mock(async () => { + throw new Error("401 Unauthorized") + }) + mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) }) + + // when / then + await expect(manager.callTool(createInfo(), createContext(), "test-tool", {})).rejects.toThrow("401 Unauthorized") + expect(refresh).toHaveBeenCalledTimes(1) + expect(callTool).toHaveBeenCalledTimes(1) + }) + + it("only attempts one refresh when the retried operation returns 401 again", async () => { + // given + const { SkillMcpManager } = await importFreshManagerModule() + const refresh = mock(async () => ({ accessToken: "refreshed-token" } satisfies OAuthTokenData)) + const manager = new SkillMcpManager({ + createOAuthProvider: () => ({ + tokens: () => ({ accessToken: "stale-token", refreshToken: "refresh-token" }), + login: mock(async () => ({ accessToken: "login-token" } satisfies OAuthTokenData)), + refresh, + }), + }) + const callTool = mock(async () => { + throw new Error("401 Unauthorized") + }) + mockGetOrCreateClientWithRetryImpl.mockResolvedValue({ callTool, close: mock(async () => {}) }) + + // when / then + await expect(manager.callTool(createInfo(), createContext(), "test-tool", {})).rejects.toThrow("401 Unauthorized") + expect(refresh).toHaveBeenCalledTimes(1) + expect(callTool).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/features/skill-mcp-manager/manager.ts b/src/features/skill-mcp-manager/manager.ts index 473d5f390..f91524be4 100644 --- a/src/features/skill-mcp-manager/manager.ts +++ b/src/features/skill-mcp-manager/manager.ts @@ -4,7 +4,7 @@ import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import { McpOAuthProvider } from "../mcp-oauth/provider" import { disconnectAll, disconnectSession, forceReconnect } from "./cleanup" import { getOrCreateClient, getOrCreateClientWithRetryImpl } from "./connection" -import { handleStepUpIfNeeded } from "./oauth-handler" +import { handlePostRequestAuthError, handleStepUpIfNeeded } from "./oauth-handler" import type { OAuthProviderFactory, SkillMcpClientInfo, @@ -110,6 +110,7 @@ export class SkillMcpManager { ): Promise { const maxRetries = 3 let lastError: Error | null = null + const refreshAttempted = new Set() for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -130,6 +131,17 @@ export class SkillMcpManager { continue } + const postRequestRefreshHandled = await handlePostRequestAuthError({ + error: lastError, + config, + authProviders: this.state.authProviders, + createOAuthProvider: this.state.createOAuthProvider, + refreshAttempted, + }) + if (postRequestRefreshHandled) { + continue + } + if (!errorMessage.includes("not connected")) { throw lastError } From 917ae4dfc37c8dc55a429533d58e717a56c31d2d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:17:14 +0900 Subject: [PATCH 55/86] fix(keyword-detector): narrow ULW auto-start to leading keyword position only Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../keyword-detector/hook-ralph-loop.test.ts | 38 +++++++++++++++++++ src/hooks/keyword-detector/hook.ts | 15 ++++++++ 2 files changed, 53 insertions(+) diff --git a/src/hooks/keyword-detector/hook-ralph-loop.test.ts b/src/hooks/keyword-detector/hook-ralph-loop.test.ts index 0ba0a6d27..ce0a6f066 100644 --- a/src/hooks/keyword-detector/hook-ralph-loop.test.ts +++ b/src/hooks/keyword-detector/hook-ralph-loop.test.ts @@ -86,6 +86,44 @@ describe("keyword-detector ralph-loop activation", () => { expect(startLoopCalls[0].options.ultrawork).toBe(true) }) + test("#given ulw mentioned mid-sentence #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "I think ulw is cool" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + expect(output.parts[0]?.text).toBe("I think ulw is cool") + }) + + test("#given question about ultrawork #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { + // given + setMainSession("main-session") + const startLoopCalls: StartLoopCall[] = [] + const ralphLoop = createMockRalphLoop(startLoopCalls) + const hook = createKeywordDetectorHook(createMockPluginInput(), undefined, ralphLoop) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "what is ultrawork?" }], + } + + // when + await hook["chat.message"]({ sessionID: "main-session", agent: "sisyphus" }, output) + + // then + expect(startLoopCalls).toHaveLength(0) + expect(output.parts[0]?.text).toBe("what is ultrawork?") + }) + test("#given non-ulw message #when chat.message fires #then ralph-loop startLoop is not invoked", async () => { // given setMainSession("main-session") diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index 1d35bbe3f..ea6348419 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -16,11 +16,16 @@ import type { RalphLoopHook } from "../ralph-loop" import { parseRalphLoopArguments } from "../ralph-loop/command-arguments" const ULTRAWORK_KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/i +const LEADING_ULTRAWORK_PATTERN = /^\s*(ultrawork|ulw)\b/i function extractUltraworkTask(cleanText: string): string { return cleanText.replace(ULTRAWORK_KEYWORD_PATTERN, "").trim() } +function hasLeadingUltraworkKeyword(cleanText: string): boolean { + return LEADING_ULTRAWORK_PATTERN.test(cleanText) +} + export function createKeywordDetectorHook( ctx: PluginInput, _collector?: ContextCollector, @@ -76,6 +81,16 @@ export function createKeywordDetectorHook( } } + if (!hasLeadingUltraworkKeyword(cleanText)) { + const preFilterCount = detectedKeywords.length + detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") + if (preFilterCount > detectedKeywords.length) { + log(`[keyword-detector] Filtered non-leading ultrawork keyword`, { + sessionID: input.sessionID, + }) + } + } + if (detectedKeywords.length === 0) { return } From 359f74132a212aa21adc8c2ab1e613f2df75cac4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:17:26 +0900 Subject: [PATCH 56/86] fix(delegate-task): strip ZWSP from agent names on background launch path Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/spawner.test.ts | 54 +++++++++++++++++++ src/features/background-agent/spawner.ts | 6 ++- .../delegate-task/background-task.test.ts | 44 +++++++++++++++ src/tools/delegate-task/background-task.ts | 8 +-- 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index d3896e55f..0d996ec19 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -466,4 +466,58 @@ describe("background-agent spawner fallback model promotion", () => { }) expect(promptCalls[0]?.body?.variant).toBe("medium") }) + + test("strips leading zwsp from prompt body agent before promptAsync", async () => { + //#given + const promptCalls: Array<{ body?: { agent?: string } }> = [] + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: "ses_child_clean_agent" } }), + promptAsync: async (args?: { body?: { agent?: string } }) => { + promptCalls.push(args ?? {}) + return {} + }, + }, + } + + const task = createTask({ + description: "Test task", + prompt: "Do work", + agent: "\u200Bsisyphus-junior", + parentSessionID: "ses_parent", + parentMessageID: "msg_parent", + }) + + const item = { + task, + input: { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionID: task.parentSessionID, + parentMessageID: task.parentMessageID, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + model: task.model, + }, + } + + const ctx = { + client, + directory: "/fallback", + concurrencyManager: { release: () => {} }, + tmuxEnabled: false, + onTaskError: () => {}, + } + + //#when + await startTask(item as any, ctx as any) + await new Promise((resolve) => setTimeout(resolve, 0)) + + //#then + expect(promptCalls).toHaveLength(1) + expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior") + }) }) diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 3c2fd7e73..b549c706b 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -6,6 +6,7 @@ import { applySessionPromptParams } from "../../shared/session-prompt-params-hel import { subagentSessions } from "../claude-code-session-state" import { getTaskToastManager } from "../task-toast-manager" import { isInsideTmux } from "../../shared/tmux" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" import type { ConcurrencyManager } from "./concurrency" export const FALLBACK_AGENT = "general" @@ -168,11 +169,12 @@ export async function startTask( } : undefined const launchVariant = input.model?.variant + const normalizedAgent = stripAgentListSortPrefix(input.agent) applySessionPromptParams(sessionID, input.model) const promptBody = { - agent: input.agent, + agent: normalizedAgent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), system: input.skillContent, @@ -180,7 +182,7 @@ export async function startTask( task: false, call_omo_agent: true, question: false, - ...getAgentToolRestrictions(input.agent), + ...getAgentToolRestrictions(normalizedAgent), }, parts: [createInternalAgentTextPart(input.prompt)], } diff --git a/src/tools/delegate-task/background-task.test.ts b/src/tools/delegate-task/background-task.test.ts index 0d95dd1dd..84a7bc644 100644 --- a/src/tools/delegate-task/background-task.test.ts +++ b/src/tools/delegate-task/background-task.test.ts @@ -204,6 +204,50 @@ describeFn("executeBackgroundTask output/session metadata compatibility", () => ]) }) + testFn("strips leading zwsp from agent name before launching background task", async () => { + //#given - display-sorted agent names should be normalized before manager launch + const launchCalls: unknown[] = [] + const manager = { + launch: async (input: unknown) => { + launchCalls.push(input) + return { + id: "bg_clean_agent", + sessionID: "ses_clean_agent", + description: "Clean agent", + agent: "sisyphus-junior", + status: "running", + } + }, + getTask: () => ({ sessionID: "ses_clean_agent" }), + } + + //#when + await executeBackgroundTask( + { + description: "Clean agent", + prompt: "check", + run_in_background: true, + load_skills: [], + }, + { + sessionID: "ses_parent", + callID: "call_clean_agent", + metadata: async () => {}, + abort: new AbortController().signal, + }, + { manager }, + { sessionID: "ses_parent", messageID: "msg_clean_agent" }, + "\u200Bsisyphus-junior", + undefined, + undefined, + undefined, + ) + + //#then + expectFn(launchCalls).toHaveLength(1) + expectFn((launchCalls[0] as { agent: string }).agent).toBe("sisyphus-junior") + }) + testFn("keeps launched background task alive when parent aborts before session id resolves", async () => { //#given - parallel tool execution can abort the parent call after launch succeeds const metadataCalls: any[] = [] diff --git a/src/tools/delegate-task/background-task.ts b/src/tools/delegate-task/background-task.ts index 0dbb042ab..184325ec9 100644 --- a/src/tools/delegate-task/background-task.ts +++ b/src/tools/delegate-task/background-task.ts @@ -10,6 +10,7 @@ import { getSessionTools } from "../../shared/session-tools-store" import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" import { setSessionFallbackChain } from "../../hooks/model-fallback/hook" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" function continueSessionSetup(args: { taskID: string @@ -62,11 +63,12 @@ export async function executeBackgroundTask( try { const tddEnabled = executorCtx.sisyphusAgentConfig?.tdd - const effectivePrompt = buildTaskPrompt(args.prompt, agentToUse, tddEnabled) + const normalizedAgent = stripAgentListSortPrefix(agentToUse) + const effectivePrompt = buildTaskPrompt(args.prompt, normalizedAgent, tddEnabled) const task = await manager.launch({ description: args.description, prompt: effectivePrompt, - agent: agentToUse, + agent: normalizedAgent, parentSessionID: parentContext.sessionID, parentMessageID: parentContext.messageID, parentModel: parentContext.model, @@ -156,7 +158,7 @@ Do NOT call background_output now. Wait for notification first return formatDetailedError(error, { operation: "Launch background task", args, - agent: agentToUse, + agent: stripAgentListSortPrefix(agentToUse), category: args.category, }) } From 15e3b14ccab1d47173e2a3ce5d68beb15a867595 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:17:41 +0900 Subject: [PATCH 57/86] fix(auto-update): match both canonical and legacy plugin names in entry finder Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../checker/plugin-entry.test.ts | 59 +++++++++++++++++++ .../checker/plugin-entry.ts | 18 +++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts index c621099b6..341839af0 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.test.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.test.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { PACKAGE_NAME } from "../constants" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" type PluginEntryResult = { entry: string @@ -120,6 +121,64 @@ describe("findPluginEntry", () => { expect(pluginInfo?.pinnedVersion).toBe("3.5.2") }) + test("finds preferred plugin entry", async () => { + // #given preferred plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: [PLUGIN_NAME] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then preferred entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(PLUGIN_NAME) + expect(pluginInfo?.isPinned).toBe(false) + expect(pluginInfo?.pinnedVersion).toBeNull() + }) + + test("finds legacy plugin entry", async () => { + // #given legacy plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: [LEGACY_PLUGIN_NAME] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then legacy entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(LEGACY_PLUGIN_NAME) + expect(pluginInfo?.isPinned).toBe(false) + expect(pluginInfo?.pinnedVersion).toBeNull() + }) + + test("finds preferred plugin entry with pinned version", async () => { + // #given preferred plugin entry includes semver version + fs.writeFileSync(configPath, JSON.stringify({ plugin: [`${PLUGIN_NAME}@3.15.0`] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then preferred versioned entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo?.entry).toBe(`${PLUGIN_NAME}@3.15.0`) + expect(pluginInfo?.isPinned).toBe(true) + expect(pluginInfo?.pinnedVersion).toBe("3.15.0") + }) + + test("returns null for unrelated plugin entry", async () => { + // #given unrelated plugin entry is configured + fs.writeFileSync(configPath, JSON.stringify({ plugin: ["some-other-plugin"] })) + + // #when plugin entry is detected + const execution = runFindPluginEntry(temporaryDirectory) + + // #then no matching entry is returned + expect(execution.status).toBe(0) + const pluginInfo = JSON.parse(execution.stdout.trim()) as PluginEntryResult + expect(pluginInfo).toBeNull() + }) + test("reads user config from profile dir even when OPENCODE_CONFIG_DIR changes after import", async () => { // #given profile-specific user config after module import const profileConfigDir = path.join(temporaryDirectory, "profiles", "today") diff --git a/src/hooks/auto-update-checker/checker/plugin-entry.ts b/src/hooks/auto-update-checker/checker/plugin-entry.ts index f204d61f1..55260c94e 100644 --- a/src/hooks/auto-update-checker/checker/plugin-entry.ts +++ b/src/hooks/auto-update-checker/checker/plugin-entry.ts @@ -3,6 +3,7 @@ import type { OpencodeConfig } from "../types" import { PACKAGE_NAME } from "../constants" import { getConfigPaths } from "./config-paths" import { stripJsonComments } from "./jsonc-strip" +import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../../shared/plugin-identity" export interface PluginEntryInfo { entry: string @@ -12,6 +13,7 @@ export interface PluginEntryInfo { } const EXACT_SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/ +const MATCH_PLUGIN_NAMES = [PACKAGE_NAME, PLUGIN_NAME, LEGACY_PLUGIN_NAME] export function findPluginEntry(directory: string): PluginEntryInfo | null { for (const configPath of getConfigPaths(directory)) { @@ -22,13 +24,15 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null { const plugins = config.plugin ?? [] for (const entry of plugins) { - if (entry === PACKAGE_NAME) { - return { entry, isPinned: false, pinnedVersion: null, configPath } - } - if (entry.startsWith(`${PACKAGE_NAME}@`)) { - const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1) - const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim()) - return { entry, isPinned, pinnedVersion, configPath } + for (const pluginName of MATCH_PLUGIN_NAMES) { + if (entry === pluginName) { + return { entry, isPinned: false, pinnedVersion: null, configPath } + } + if (entry.startsWith(`${pluginName}@`)) { + const pinnedVersion = entry.slice(pluginName.length + 1) + const isPinned = EXACT_SEMVER_REGEX.test(pinnedVersion.trim()) + return { entry, isPinned, pinnedVersion, configPath } + } } } } catch { From 001d29ea8e4a54565802c678820f3d64e09aa3fd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:18:17 +0900 Subject: [PATCH 58/86] fix(skill-mcp): treat opencode-project and local scopes as untrusted for env var access Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../connection-env-vars.test.ts | 71 ++++++++++++++++++- src/features/skill-mcp-manager/connection.ts | 4 +- src/features/skill-mcp-manager/types.ts | 2 +- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/features/skill-mcp-manager/connection-env-vars.test.ts b/src/features/skill-mcp-manager/connection-env-vars.test.ts index a535bcb47..60cf20ce6 100644 --- a/src/features/skill-mcp-manager/connection-env-vars.test.ts +++ b/src/features/skill-mcp-manager/connection-env-vars.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { afterAll, afterEach, beforeEach, describe, expect, it, mock, test } from "bun:test" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { SkillMcpClientInfo, SkillMcpManagerState } from "./types" @@ -89,12 +89,15 @@ function createState(): SkillMcpManagerState { return state } -function createClientInfo(serverName: string): SkillMcpClientInfo { +function createClientInfo( + serverName: string, + scope?: SkillMcpClientInfo["scope"], +): SkillMcpClientInfo { return { serverName, skillName: "env-skill", sessionID: "session-env", - scope: "builtin", + ...(scope !== undefined ? { scope } : {}), } } @@ -126,6 +129,68 @@ afterEach(async () => { }) describe("getOrCreateClient env var expansion", () => { + describe("#given a scope-sensitive stdio skill MCP config", () => { + test.each([ + ["opencode-project", "Authorization:Bearer "], + ["local", "Authorization:Bearer "], + ["user", "Authorization:Bearer xoxp-scope-token"], + ["builtin", "Authorization:Bearer xoxp-scope-token"], + ] satisfies Array<[NonNullable, string]>) ( + "#when creating the client for %s scope #then args expand to %s", + async (scope, expectedAuthorizationHeader) => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-scope-token" + const state = createState() + const info = createClientInfo(`scope-${scope}`, scope) + const clientKey = createClientKey(info) + const config: ClaudeCodeMcpServer = { + command: "npx", + args: [ + "-y", + "mcp-remote", + "https://mcp.slack.com/mcp", + "--header", + "Authorization:Bearer ${SLACK_USER_TOKEN}", + ], + } + + // when + await getOrCreateClient({ state, clientKey, info, config }) + + // then + expect(createdStdioTransports).toHaveLength(1) + expect(createdStdioTransports[0]?.options.args?.[4]).toBe(expectedAuthorizationHeader) + }, + ) + + it("#when creating the client without scope #then env vars remain trusted for backward compatibility", async () => { + // given + process.env.SLACK_USER_TOKEN = "xoxp-undefined-scope-token" + const state = createState() + const info = createClientInfo("scope-undefined") + const clientKey = createClientKey(info) + const config: ClaudeCodeMcpServer = { + command: "npx", + args: [ + "-y", + "mcp-remote", + "https://mcp.slack.com/mcp", + "--header", + "Authorization:Bearer ${SLACK_USER_TOKEN}", + ], + } + + // when + await getOrCreateClient({ state, clientKey, info, config }) + + // then + expect(createdStdioTransports).toHaveLength(1) + expect(createdStdioTransports[0]?.options.args?.[4]).toBe( + "Authorization:Bearer xoxp-undefined-scope-token", + ) + }) + }) + describe("#given a stdio skill MCP config with sensitive env vars in args", () => { it("#when creating the client #then sensitive env vars in args are expanded", async () => { // given diff --git a/src/features/skill-mcp-manager/connection.ts b/src/features/skill-mcp-manager/connection.ts index 2826492b0..2fa4dc3a3 100644 --- a/src/features/skill-mcp-manager/connection.ts +++ b/src/features/skill-mcp-manager/connection.ts @@ -14,6 +14,8 @@ function removeClientIfCurrent(state: SkillMcpManagerState, clientKey: string, c } } +const PROJECT_SCOPES = new Set(["project", "opencode-project", "local"]) + export async function getOrCreateClient(params: { state: SkillMcpManagerState clientKey: string @@ -38,7 +40,7 @@ export async function getOrCreateClient(params: { return pending } - const isTrusted = info.scope !== "project" + const isTrusted = !PROJECT_SCOPES.has(info.scope ?? "") const expandedConfig = expandEnvVarsInObject(config, { trusted: isTrusted }) let currentConnectionPromise!: Promise state.inFlightConnections.set(info.sessionID, (state.inFlightConnections.get(info.sessionID) ?? 0) + 1) diff --git a/src/features/skill-mcp-manager/types.ts b/src/features/skill-mcp-manager/types.ts index 3d2838d55..75ef396cf 100644 --- a/src/features/skill-mcp-manager/types.ts +++ b/src/features/skill-mcp-manager/types.ts @@ -11,7 +11,7 @@ export interface SkillMcpClientInfo { serverName: string skillName: string sessionID: string - scope?: SkillScope + scope?: SkillScope | "local" } export interface SkillMcpServerContext { From aa0de17f03067ade863afde8fc71d542ca1c2d53 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:18:23 +0900 Subject: [PATCH 59/86] fix(start-work): preserve non-ASCII characters in plan name normalization Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/start-work/context-info-builder.ts | 2 +- src/hooks/start-work/index.test.ts | 116 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/hooks/start-work/context-info-builder.ts b/src/hooks/start-work/context-info-builder.ts index 2fe074429..ecbe1d37a 100644 --- a/src/hooks/start-work/context-info-builder.ts +++ b/src/hooks/start-work/context-info-builder.ts @@ -23,7 +23,7 @@ function normalizePlanLookupValue(value: string): string { .replace(/^["'`]+|["'`]+$/g, "") .toLowerCase() .replace(/[\s_]+/g, "-") - .replace(/[^a-z0-9-]+/g, "-") + .replace(/[^\p{L}\p{N}-]+/gu, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") } diff --git a/src/hooks/start-work/index.test.ts b/src/hooks/start-work/index.test.ts index c2a4fb09a..63f37f06d 100644 --- a/src/hooks/start-work/index.test.ts +++ b/src/hooks/start-work/index.test.ts @@ -443,6 +443,122 @@ You are starting a Sisyphus work session. expect(output.parts[0].text).toContain("my-feature-plan") expect(output.parts[0].text).toContain("Auto-Selected Plan") }) + + test("should match Korean plan names after Unicode-aware normalization", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "결제-플로우.md") + writeFileSync(planPath, "# 결제 플로우\n- [ ] 작업 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "결제 플로우" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-korean-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("결제-플로우") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match Japanese plan names after Unicode-aware normalization", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "支払い-フロー.md") + writeFileSync(planPath, "# 支払い フロー\n- [ ] タスク 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "支払い フロー" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-japanese-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("支払い-フロー") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should keep ASCII plan name matching behavior unchanged", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "checkout-flow.md") + writeFileSync(planPath, "# Checkout Flow\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "checkout flow" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-ascii-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("checkout-flow") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) + + test("should match mixed ASCII and non-ASCII plan names", async () => { + // given + const plansDir = join(testDir, ".sisyphus", "plans") + mkdirSync(plansDir, { recursive: true }) + + const planPath = join(plansDir, "v2-결제-flow.md") + writeFileSync(planPath, "# v2 결제 flow\n- [ ] Task 1") + + const hook = createStartWorkHook(createMockPluginInput()) + const output = { + parts: [ + { + type: "text", + text: createStartWorkPrompt({ userRequest: "v2 결제 flow" }), + }, + ], + } + + // when + await hook["chat.message"]( + { sessionID: "session-mixed-plan" }, + output, + ) + + // then + expect(output.parts[0].text).toContain("v2-결제-flow") + expect(output.parts[0].text).toContain("Auto-Selected Plan") + }) }) describe("session agent management", () => { From 0cb938e3aca9800d8c658b2751e5491fb9a910c0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:18:29 +0900 Subject: [PATCH 60/86] fix(boulder): count only top-level checkboxes in simple-mode plan progress Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/boulder-state/storage.test.ts | 59 ++++++++++++++++++++++ src/features/boulder-state/storage.ts | 8 +-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 65f6ad87e..4326b42e0 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -650,6 +650,65 @@ describe("boulder-state", () => { expect(progress.completed).toBe(1) expect(progress.isComplete).toBe(false) }) + + test("should count only top-level checkboxes for simple plans with nested tasks", () => { + // given + const planPath = join(TEST_DIR, "simple-nested-plan.md") + writeFileSync(planPath, `# Plan + +- [ ] Top-level task 1 + - [x] Nested task ignored +- [x] Top-level task 2 + * [ ] Another nested task ignored +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) + + test("should treat final-wave-only plans as structured mode", () => { + // given + const planPath = join(TEST_DIR, "final-wave-only-plan.md") + writeFileSync(planPath, `# Plan + +## Final Verification Wave +- [ ] F1. Top-level final review + - [x] Nested verification detail ignored +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(1) + expect(progress.completed).toBe(0) + expect(progress.isComplete).toBe(false) + }) + + test("should ignore mixed indentation levels in simple plans", () => { + // given + const planPath = join(TEST_DIR, "simple-mixed-indentation-plan.md") + writeFileSync(planPath, `# Plan + +* [x] Top-level star task + - [ ] Indented task ignored + - [x] Tab-indented task ignored +- [ ] Top-level dash task +`) + + // when + const progress = getPlanProgress(planPath) + + // then + expect(progress.total).toBe(2) + expect(progress.completed).toBe(1) + expect(progress.isComplete).toBe(false) + }) }) describe("getPlanName", () => { diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index 1d5dc2a59..d570ce525 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -226,7 +226,9 @@ export function getPlanProgress(planPath: string): PlanProgress { const lines = content.split(/\r?\n/) // Check if the plan has structured sections (## TODOs / ## Final Verification Wave) - const hasStructuredSections = lines.some((line) => TODO_HEADING_PATTERN.test(line)) + const hasStructuredSections = lines.some( + (line) => TODO_HEADING_PATTERN.test(line) || FINAL_VERIFICATION_HEADING_PATTERN.test(line), + ) if (hasStructuredSections) { // Structured plan: only count top-level checkboxes with numbered labels @@ -291,8 +293,8 @@ function getStructuredPlanProgress(lines: string[]): PlanProgress { } function getSimplePlanProgress(content: string): PlanProgress { - const uncheckedMatches = content.match(/^\s*[-*]\s*\[\s*\]/gm) || [] - const checkedMatches = content.match(/^\s*[-*]\s*\[[xX]\]/gm) || [] + const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || [] + const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || [] const total = uncheckedMatches.length + checkedMatches.length const completed = checkedMatches.length From bbbbf68382b11625aec995db4137dd82a6ac2583 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:18:37 +0900 Subject: [PATCH 61/86] fix(ralph-loop): update template to reflect 500 iteration cap Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../builtin-commands/templates/ralph-loop.test.ts | 15 +++++++++++++++ .../builtin-commands/templates/ralph-loop.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/features/builtin-commands/templates/ralph-loop.test.ts diff --git a/src/features/builtin-commands/templates/ralph-loop.test.ts b/src/features/builtin-commands/templates/ralph-loop.test.ts new file mode 100644 index 000000000..ae8440ae1 --- /dev/null +++ b/src/features/builtin-commands/templates/ralph-loop.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { ULW_LOOP_TEMPLATE } from "./ralph-loop" + +describe("ULW_LOOP_TEMPLATE", () => { + test("returns the documented iteration caps for ultrawork and normal modes", () => { + // given + const expectedIterationCaps = "The iteration limit is 500 for ultrawork mode, 100 for normal mode" + + // when + const template = ULW_LOOP_TEMPLATE + + // then + expect(template).toContain(expectedIterationCaps) + }) +}) diff --git a/src/features/builtin-commands/templates/ralph-loop.ts b/src/features/builtin-commands/templates/ralph-loop.ts index 5da026a70..1fb8bae50 100644 --- a/src/features/builtin-commands/templates/ralph-loop.ts +++ b/src/features/builtin-commands/templates/ralph-loop.ts @@ -36,7 +36,7 @@ export const ULW_LOOP_TEMPLATE = `You are starting an ULTRAWORK Loop - a self-re 2. When you believe the work is complete, output: \`{{COMPLETION_PROMISE}}\` 3. That does NOT finish the loop yet. The system will require Oracle verification 4. The loop only ends after the system confirms Oracle verified the result -5. There is no iteration limit +5. The iteration limit is 500 for ultrawork mode, 100 for normal mode ## Rules From 389b194fbf55b74b0c81553360a5d6fd6516eee3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:19:52 +0900 Subject: [PATCH 62/86] fix(installer): actually upgrade pinned plugin version instead of preserving old entry Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../add-plugin-to-opencode-config.ts | 8 ++--- .../config-manager/plugin-detection.test.ts | 36 +++++++++++++++---- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/cli/config-manager/add-plugin-to-opencode-config.ts b/src/cli/config-manager/add-plugin-to-opencode-config.ts index 208abd56a..23c398873 100644 --- a/src/cli/config-manager/add-plugin-to-opencode-config.ts +++ b/src/cli/config-manager/add-plugin-to-opencode-config.ts @@ -79,12 +79,8 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise const normalizedPlugins = [...otherPlugins] - if (canonicalEntries.length > 0) { - normalizedPlugins.push(canonicalEntries[0]) - } else if (legacyEntries.length > 0) { - const versionMatch = legacyEntries[0].match(/@(.+)$/) - const preservedVersion = versionMatch ? versionMatch[1] : null - normalizedPlugins.push(preservedVersion ? `${PLUGIN_NAME}@${preservedVersion}` : pluginEntry) + if (canonicalEntries.length > 0 || legacyEntries.length > 0) { + normalizedPlugins.push(pluginEntry) } else { normalizedPlugins.push(pluginEntry) } diff --git a/src/cli/config-manager/plugin-detection.test.ts b/src/cli/config-manager/plugin-detection.test.ts index e03e63357..fcd6109f9 100644 --- a/src/cli/config-manager/plugin-detection.test.ts +++ b/src/cli/config-manager/plugin-detection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -6,6 +6,7 @@ import { join } from "node:path" import { resetConfigContext } from "./config-context" import { detectCurrentConfig } from "./detect-current-config" import { addPluginToOpenCodeConfig } from "./add-plugin-to-opencode-config" +import * as pluginNameWithVersion from "./plugin-name-with-version" describe("detectCurrentConfig - single package detection", () => { let testConfigDir = "" @@ -109,17 +110,19 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) }) - it("upgrades a version-pinned legacy entry to canonical", async () => { + it("updates a version-pinned legacy entry to the requested version", async () => { // given - writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", "utf-8") + const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.16.0") + writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode@3.15.0"] }, null, 2) + "\n", "utf-8") // when - const result = await addPluginToOpenCodeConfig("3.11.0") + const result = await addPluginToOpenCodeConfig("3.16.0") // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) - expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"]) + expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"]) + getPluginNameWithVersionSpy.mockRestore() }) it("removes stale legacy entry when canonical and legacy entries both exist", async () => { @@ -135,17 +138,36 @@ describe("addPluginToOpenCodeConfig - single package writes", () => { expect(savedConfig.plugin).toEqual(["oh-my-openagent"]) }) - it("preserves a canonical entry when it already exists", async () => { + it("preserves a canonical entry when the same version is re-installed", async () => { // given + const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.10.0") writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.10.0"] }, null, 2) + "\n", "utf-8") // when - const result = await addPluginToOpenCodeConfig("3.11.0") + const result = await addPluginToOpenCodeConfig("3.10.0") // then expect(result.success).toBe(true) const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.10.0"]) + getPluginNameWithVersionSpy.mockRestore() + }) + + it("blocks a downgrade for a version-pinned canonical entry", async () => { + // given + const getPluginNameWithVersionSpy = spyOn(pluginNameWithVersion, "getPluginNameWithVersion").mockResolvedValue("oh-my-openagent@3.15.0") + writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-openagent@3.16.0"] }, null, 2) + "\n", "utf-8") + + // when + const result = await addPluginToOpenCodeConfig("3.15.0") + + // then + expect(result.success).toBe(false) + expect(result.error).toContain("Downgrade") + + const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8")) + expect(savedConfig.plugin).toEqual(["oh-my-openagent@3.16.0"]) + getPluginNameWithVersionSpy.mockRestore() }) it("rewrites quoted jsonc plugin field in place", async () => { From 6d8d82d7603776d28aff5a2be695f857ad639be9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:20:00 +0900 Subject: [PATCH 63/86] fix(installer): enforce minimum OpenCode version check during install Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/cli-installer.test.ts | 59 +++++++++++-- src/cli/cli-installer.ts | 7 ++ src/cli/install.test.ts | 4 +- src/cli/minimum-opencode-version.ts | 14 +++ src/cli/tui-installer.test.ts | 129 ++++++++++++++++++++++++++++ src/cli/tui-installer.ts | 8 ++ 6 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 src/cli/minimum-opencode-version.ts create mode 100644 src/cli/tui-installer.test.ts diff --git a/src/cli/cli-installer.test.ts b/src/cli/cli-installer.test.ts index 5d5fd0ca5..934d18322 100644 --- a/src/cli/cli-installer.test.ts +++ b/src/cli/cli-installer.test.ts @@ -21,11 +21,12 @@ describe("runCliInstaller", () => { console.error = originalConsoleError }) - it("completes installation without auth plugin or provider config steps", async () => { - //#given + it("blocks installation when OpenCode is below the minimum version", async () => { + // given const restoreSpies = [ spyOn(configManager, "detectCurrentConfig").mockReturnValue({ isInstalled: false, + installedVersion: null, hasClaude: false, isMax20: false, hasOpenAI: false, @@ -34,9 +35,56 @@ describe("runCliInstaller", () => { hasOpencodeZen: false, hasZaiCodingPlan: false, hasKimiForCoding: false, + hasOpencodeGo: false, }), spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), - spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"), + ] + const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig") + + const args: InstallArgs = { + tui: false, + claude: "no", + openai: "no", + gemini: "no", + copilot: "no", + opencodeZen: "no", + zaiCodingPlan: "no", + kimiForCoding: "no", + opencodeGo: "no", + } + + // when + const result = await runCliInstaller(args, "3.16.0") + + // then + expect(result).toBe(1) + expect(addPluginSpy).not.toHaveBeenCalled() + + for (const spy of restoreSpies) { + spy.mockRestore() + } + addPluginSpy.mockRestore() + }) + + it("completes installation without auth plugin or provider config steps", async () => { + // given + const restoreSpies = [ + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ success: true, configPath: "/tmp/opencode.jsonc", @@ -56,12 +104,13 @@ describe("runCliInstaller", () => { opencodeZen: "no", zaiCodingPlan: "no", kimiForCoding: "no", + opencodeGo: "no", } - //#when + // when const result = await runCliInstaller(args, "3.4.0") - //#then + // then expect(result).toBe(0) for (const spy of restoreSpies) { diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 220ba2879..0808488aa 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -22,6 +22,7 @@ import { printWarning, validateNonTuiArgs, } from "./install-validators" +import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" export async function runCliInstaller(args: InstallArgs, version: string): Promise { const validation = validateNonTuiArgs(args) @@ -57,6 +58,12 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi printInfo("Visit https://opencode.ai/docs for installation instructions") } else { printSuccess(`OpenCode ${openCodeVersion ?? ""} detected`) + + const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) + if (unsupportedVersionMessage) { + printWarning(unsupportedVersionMessage) + return 1 + } } if (isUpdate) { diff --git a/src/cli/install.test.ts b/src/cli/install.test.ts index cf4b7f633..61bcf645f 100644 --- a/src/cli/install.test.ts +++ b/src/cli/install.test.ts @@ -128,7 +128,7 @@ describe("install CLI - binary check behavior", () => { test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => { // given OpenCode binary IS installed isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true) - getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200") + getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0") // given mock npm fetch globalThis.fetch = mock(() => @@ -157,6 +157,6 @@ describe("install CLI - binary check behavior", () => { // then should have printed success (OK symbol) const allCalls = mockConsoleLog.mock.calls.flat().join("\n") expect(allCalls).toContain("[OK]") - expect(allCalls).toContain("OpenCode 1.0.200") + expect(allCalls).toContain("OpenCode 1.4.0") }) }) diff --git a/src/cli/minimum-opencode-version.ts b/src/cli/minimum-opencode-version.ts new file mode 100644 index 000000000..93804568c --- /dev/null +++ b/src/cli/minimum-opencode-version.ts @@ -0,0 +1,14 @@ +import { MIN_OPENCODE_VERSION } from "./doctor/constants" +import { compareVersions } from "../shared/opencode-version" + +export function getUnsupportedOpenCodeVersionMessage(openCodeVersion: string | null): string | null { + if (!openCodeVersion) { + return null + } + + if (compareVersions(openCodeVersion, MIN_OPENCODE_VERSION) >= 0) { + return null + } + + return `Detected OpenCode ${openCodeVersion}, but ${MIN_OPENCODE_VERSION}+ is required. Update OpenCode, then rerun the installer.` +} diff --git a/src/cli/tui-installer.test.ts b/src/cli/tui-installer.test.ts new file mode 100644 index 000000000..dc5ca718f --- /dev/null +++ b/src/cli/tui-installer.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" +import * as p from "@clack/prompts" +import * as configManager from "./config-manager" +import * as tuiInstallPrompts from "./tui-install-prompts" +import { runTuiInstaller } from "./tui-installer" + +function createMockSpinner(): ReturnType { + return { + start: () => undefined, + stop: () => undefined, + message: () => undefined, + } +} + +describe("runTuiInstaller", () => { + const originalIsStdinTty = process.stdin.isTTY + const originalIsStdoutTty = process.stdout.isTTY + + beforeEach(() => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }) + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }) + }) + + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalIsStdinTty }) + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalIsStdoutTty }) + }) + + it("blocks installation when OpenCode is below the minimum version", async () => { + // given + const restoreSpies = [ + spyOn(p, "spinner").mockReturnValue(createMockSpinner()), + spyOn(p, "intro").mockImplementation(() => undefined), + spyOn(p.log, "warn").mockImplementation(() => undefined), + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.3.9"), + ] + const promptSpy = spyOn(tuiInstallPrompts, "promptInstallConfig") + const addPluginSpy = spyOn(configManager, "addPluginToOpenCodeConfig") + const outroSpy = spyOn(p, "outro").mockImplementation(() => undefined) + + // when + const result = await runTuiInstaller({ tui: true }, "3.16.0") + + // then + expect(result).toBe(1) + expect(promptSpy).not.toHaveBeenCalled() + expect(addPluginSpy).not.toHaveBeenCalled() + expect(outroSpy).toHaveBeenCalled() + + for (const spy of restoreSpies) { + spy.mockRestore() + } + promptSpy.mockRestore() + addPluginSpy.mockRestore() + outroSpy.mockRestore() + }) + + it("proceeds when OpenCode meets the minimum version", async () => { + // given + const restoreSpies = [ + spyOn(p, "spinner").mockReturnValue(createMockSpinner()), + spyOn(p, "intro").mockImplementation(() => undefined), + spyOn(p.log, "info").mockImplementation(() => undefined), + spyOn(p.log, "warn").mockImplementation(() => undefined), + spyOn(p.log, "success").mockImplementation(() => undefined), + spyOn(p.log, "message").mockImplementation(() => undefined), + spyOn(p, "note").mockImplementation(() => undefined), + spyOn(p, "outro").mockImplementation(() => undefined), + spyOn(configManager, "detectCurrentConfig").mockReturnValue({ + isInstalled: false, + installedVersion: null, + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + }), + spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true), + spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.4.0"), + spyOn(tuiInstallPrompts, "promptInstallConfig").mockResolvedValue({ + hasClaude: false, + isMax20: false, + hasOpenAI: false, + hasGemini: false, + hasCopilot: false, + hasOpencodeZen: false, + hasZaiCodingPlan: false, + hasKimiForCoding: false, + hasOpencodeGo: false, + }), + spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({ + success: true, + configPath: "/tmp/opencode.jsonc", + }), + spyOn(configManager, "writeOmoConfig").mockReturnValue({ + success: true, + configPath: "/tmp/oh-my-opencode.jsonc", + }), + ] + + // when + const result = await runTuiInstaller({ tui: true }, "3.16.0") + + // then + expect(result).toBe(0) + + for (const spy of restoreSpies) { + spy.mockRestore() + } + }) +}) diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index 68f075474..973e387f4 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -10,6 +10,7 @@ import { writeOmoConfig, } from "./config-manager" import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" +import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { promptInstallConfig } from "./tui-install-prompts" export async function runTuiInstaller(args: InstallArgs, version: string): Promise { @@ -39,6 +40,13 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide") } else { spinner.stop(`OpenCode ${openCodeVersion ?? "installed"} ${color.green("[OK]")}`) + + const unsupportedVersionMessage = getUnsupportedOpenCodeVersionMessage(openCodeVersion) + if (unsupportedVersionMessage) { + p.log.warn(unsupportedVersionMessage) + p.outro(color.red("Installation blocked.")) + return 1 + } } const config = await promptInstallConfig(detected) From 119c23342a8f1565e53e1c707c58dacee8823eb4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:25:54 +0900 Subject: [PATCH 64/86] test(background): fix variant propagation test to match parent-context resolution --- src/features/background-agent/manager.test.ts | 68 ++++--------------- 1 file changed, 14 insertions(+), 54 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 67b584d4b..5a3a430e6 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1063,18 +1063,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => { prompt: promptMock, promptAsync: promptMock, abort: async () => ({}), - messages: async () => ({ - data: [{ - info: { - agent: "explore", - model: { - providerID: "anthropic", - modelID: "claude-opus-4-6", - variant: "high", - }, - }, - }], - }), + messages: async () => ({ data: [] }), }, } const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) @@ -1159,7 +1148,18 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => { prompt: promptMock, promptAsync: promptMock, abort: async () => ({}), - messages: async () => ({ data: [] }), + messages: async () => ({ + data: [{ + info: { + agent: "explore", + model: { + providerID: "anthropic", + modelID: "claude-opus-4-6", + variant: "high", + }, + }, + }], + }), }, } const manager = new BackgroundManager( @@ -1193,47 +1193,6 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => { }) describe("BackgroundManager.notifyParentSession - variant propagation", () => { - test("should propagate variant in parent notification promptAsync body", async () => { - //#given - const promptCalls: Array<{ body: Record }> = [] - const client = { - session: { - prompt: async () => ({}), - promptAsync: async (args: { path: { id: string }; body: Record }) => { - promptCalls.push({ body: args.body }) - return {} - }, - abort: async () => ({}), - messages: async () => ({ data: [] }), - }, - } - const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) - const task: BackgroundTask = { - id: "task-variant-test", - sessionID: "session-child", - parentSessionID: "session-parent", - parentMessageID: "msg-parent", - description: "task with variant", - prompt: "test", - agent: "explore", - status: "completed", - startedAt: new Date(), - completedAt: new Date(), - model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "high" }, - } - getPendingByParent(manager).set("session-parent", new Set([task.id])) - - //#when - await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) - .notifyParentSession(task) - - //#then - expect(promptCalls).toHaveLength(1) - expect(promptCalls[0].body.variant).toBe("high") - - manager.shutdown() - }) - test("should prefer parent session variant over child task variant in parent notification promptAsync body", async () => { //#given const promptCalls: Array<{ body: Record }> = [] @@ -1588,6 +1547,7 @@ describe("BackgroundManager.tryCompleteTask", () => { const task = createMockTask({ id: "task-zombie-session", + sessionID: "session-zombie-placeholder", parentSessionID: "parent-zombie", status: "pending", agent: "explore", From 85fa939051af798a0e8421db2515a115f18db191 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:26:00 +0900 Subject: [PATCH 65/86] test(skill-mcp): fix connection env var tests after oauth-handler import changes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../manager-oauth-retry.test.ts | 18 +++++++++--------- .../skill-mcp-manager/oauth-handler.test.ts | 14 +++++--------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/features/skill-mcp-manager/manager-oauth-retry.test.ts b/src/features/skill-mcp-manager/manager-oauth-retry.test.ts index 5d6dabd77..f887e80e7 100644 --- a/src/features/skill-mcp-manager/manager-oauth-retry.test.ts +++ b/src/features/skill-mcp-manager/manager-oauth-retry.test.ts @@ -12,18 +12,18 @@ const mockGetOrCreateClientWithRetryImpl = mock(async () => ({ close: mock(async () => {}), })) -mock.module("./connection", () => ({ - getOrCreateClient: mockGetOrCreateClient, - getOrCreateClientWithRetryImpl: mockGetOrCreateClientWithRetryImpl, -})) - -mock.module("../mcp-oauth/provider", () => ({ - McpOAuthProvider: class MockMcpOAuthProvider {}, -})) - type ManagerModule = typeof import("./manager") async function importFreshManagerModule(): Promise { + mock.module("./connection", () => ({ + getOrCreateClient: mockGetOrCreateClient, + getOrCreateClientWithRetryImpl: mockGetOrCreateClientWithRetryImpl, + })) + + mock.module("../mcp-oauth/provider", () => ({ + McpOAuthProvider: class MockMcpOAuthProvider {}, + })) + return await import(new URL(`./manager.ts?oauth-retry-test=${Date.now()}-${Math.random()}`, import.meta.url).href) } diff --git a/src/features/skill-mcp-manager/oauth-handler.test.ts b/src/features/skill-mcp-manager/oauth-handler.test.ts index d6eb317bc..35823c6ae 100644 --- a/src/features/skill-mcp-manager/oauth-handler.test.ts +++ b/src/features/skill-mcp-manager/oauth-handler.test.ts @@ -1,15 +1,15 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test" +import { describe, expect, it, mock } from "bun:test" import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types" import type { OAuthTokenData } from "../mcp-oauth/storage" import type { OAuthProviderFactory, OAuthProviderLike } from "./types" -mock.module("../mcp-oauth/provider", () => ({ - McpOAuthProvider: class MockMcpOAuthProvider {}, -})) - type OAuthHandlerModule = typeof import("./oauth-handler") async function importFreshOAuthHandlerModule(): Promise { + mock.module("../mcp-oauth/provider", () => ({ + McpOAuthProvider: class MockMcpOAuthProvider {}, + })) + return await import(new URL(`./oauth-handler.ts?oauth-handler-test=${Date.now()}-${Math.random()}`, import.meta.url).href) } @@ -41,10 +41,6 @@ function createConfig(serverUrl: string): ClaudeCodeMcpServer { } describe("oauth-handler refresh mutex wiring", () => { - beforeEach(() => { - mock.restore() - }) - it("deduplicates concurrent pre-request refresh attempts for the same server", async () => { // given const { buildHttpRequestInit } = await importFreshOAuthHandlerModule() From 70955b2b97fb24cb843b1c5d8dfee59552f41300 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:30:43 +0900 Subject: [PATCH 66/86] fix(ci): isolate mock.module tests per-file to prevent cross-contamination --- script/run-ci-tests.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index 5466885ce..10cf80b21 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise< } function toIsolatedTarget(testFile: string): string { - const pathSegments = testFile.split("/") - - if (pathSegments.length <= 3) { - return testFile - } - - return pathSegments.slice(0, -1).join("/") + return testFile } function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean { From dc1546c613f41715eec43057f0b1d96f1b68a282 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:30:43 +0900 Subject: [PATCH 67/86] fix(ci): isolate mock.module tests per-file to prevent cross-contamination --- script/run-ci-tests.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index 5466885ce..10cf80b21 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise< } function toIsolatedTarget(testFile: string): string { - const pathSegments = testFile.split("/") - - if (pathSegments.length <= 3) { - return testFile - } - - return pathSegments.slice(0, -1).join("/") + return testFile } function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean { From 28b9f777d28e815c4caf07d66536f824e0a54137 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:30:43 +0900 Subject: [PATCH 68/86] fix(ci): isolate mock.module tests per-file to prevent cross-contamination --- script/run-ci-tests.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index 5466885ce..10cf80b21 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise< } function toIsolatedTarget(testFile: string): string { - const pathSegments = testFile.split("/") - - if (pathSegments.length <= 3) { - return testFile - } - - return pathSegments.slice(0, -1).join("/") + return testFile } function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean { From 9fb7cfb3f51d80cb31604ee640ce83c3181711bf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:30:43 +0900 Subject: [PATCH 69/86] fix(ci): isolate mock.module tests per-file to prevent cross-contamination --- script/run-ci-tests.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index 5466885ce..10cf80b21 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise< } function toIsolatedTarget(testFile: string): string { - const pathSegments = testFile.split("/") - - if (pathSegments.length <= 3) { - return testFile - } - - return pathSegments.slice(0, -1).join("/") + return testFile } function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean { From 2f4bd480fc87414b28623b599587f564fb075854 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:30:43 +0900 Subject: [PATCH 70/86] fix(ci): isolate mock.module tests per-file to prevent cross-contamination --- script/run-ci-tests.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts index 5466885ce..10cf80b21 100644 --- a/script/run-ci-tests.ts +++ b/script/run-ci-tests.ts @@ -29,13 +29,7 @@ async function usesModuleMock(rootDirectory: string, testFile: string): Promise< } function toIsolatedTarget(testFile: string): string { - const pathSegments = testFile.split("/") - - if (pathSegments.length <= 3) { - return testFile - } - - return pathSegments.slice(0, -1).join("/") + return testFile } function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean { From 4c0225a23f34f10954748e6c46a15451a2b085d9 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:36:23 +0900 Subject: [PATCH 71/86] test(tmux): add missing tmux exports to zombie-pane mock module --- src/features/tmux-subagent/zombie-pane.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 42fcfb760..267c03cb3 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({ mock.module("../../shared/tmux", () => ({ isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, + isServerRunning: mock(async () => true), + resetServerCheck: mock(() => {}), + markServerRunningInProcess: mock(() => {}), + getPaneDimensions: mock(async () => ({ width: 220, height: 44 })), + spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + closeTmuxPane: mock(async () => ({ success: true })), + replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })), + spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })), + applyLayout: mock(async () => ({ success: true })), + enforceMainPaneWidth: mock(async () => ({ success: true })), POLL_INTERVAL_BACKGROUND_MS: 10, SESSION_READY_POLL_INTERVAL_MS: 10, SESSION_READY_TIMEOUT_MS: 50, SESSION_MISSING_GRACE_MS: 1_000, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) From d53be83634768172832ca52d603a33227fcfa266 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:36:23 +0900 Subject: [PATCH 72/86] test(tmux): add missing tmux exports to zombie-pane mock module --- src/features/tmux-subagent/zombie-pane.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 42fcfb760..267c03cb3 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({ mock.module("../../shared/tmux", () => ({ isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, + isServerRunning: mock(async () => true), + resetServerCheck: mock(() => {}), + markServerRunningInProcess: mock(() => {}), + getPaneDimensions: mock(async () => ({ width: 220, height: 44 })), + spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + closeTmuxPane: mock(async () => ({ success: true })), + replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })), + spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })), + applyLayout: mock(async () => ({ success: true })), + enforceMainPaneWidth: mock(async () => ({ success: true })), POLL_INTERVAL_BACKGROUND_MS: 10, SESSION_READY_POLL_INTERVAL_MS: 10, SESSION_READY_TIMEOUT_MS: 50, SESSION_MISSING_GRACE_MS: 1_000, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) From 600d68da0413f3c71dd02dc1b4987100295265c4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:36:23 +0900 Subject: [PATCH 73/86] test(tmux): add missing tmux exports to zombie-pane mock module --- src/features/tmux-subagent/zombie-pane.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 42fcfb760..267c03cb3 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({ mock.module("../../shared/tmux", () => ({ isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, + isServerRunning: mock(async () => true), + resetServerCheck: mock(() => {}), + markServerRunningInProcess: mock(() => {}), + getPaneDimensions: mock(async () => ({ width: 220, height: 44 })), + spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + closeTmuxPane: mock(async () => ({ success: true })), + replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })), + spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })), + applyLayout: mock(async () => ({ success: true })), + enforceMainPaneWidth: mock(async () => ({ success: true })), POLL_INTERVAL_BACKGROUND_MS: 10, SESSION_READY_POLL_INTERVAL_MS: 10, SESSION_READY_TIMEOUT_MS: 50, SESSION_MISSING_GRACE_MS: 1_000, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) From 7f8ed7b056d74f45bc91dcc7d0a2bfc7095a3341 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:36:23 +0900 Subject: [PATCH 74/86] test(tmux): add missing tmux exports to zombie-pane mock module --- src/features/tmux-subagent/zombie-pane.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 42fcfb760..267c03cb3 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({ mock.module("../../shared/tmux", () => ({ isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, + isServerRunning: mock(async () => true), + resetServerCheck: mock(() => {}), + markServerRunningInProcess: mock(() => {}), + getPaneDimensions: mock(async () => ({ width: 220, height: 44 })), + spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + closeTmuxPane: mock(async () => ({ success: true })), + replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })), + spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })), + applyLayout: mock(async () => ({ success: true })), + enforceMainPaneWidth: mock(async () => ({ success: true })), POLL_INTERVAL_BACKGROUND_MS: 10, SESSION_READY_POLL_INTERVAL_MS: 10, SESSION_READY_TIMEOUT_MS: 50, SESSION_MISSING_GRACE_MS: 1_000, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) From 5abab08eef58c7e9c88ab4fd7acf646ca337760a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 17:36:23 +0900 Subject: [PATCH 75/86] test(tmux): add missing tmux exports to zombie-pane mock module --- src/features/tmux-subagent/zombie-pane.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 42fcfb760..267c03cb3 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -40,10 +40,22 @@ mock.module("./action-executor", () => ({ mock.module("../../shared/tmux", () => ({ isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, + isServerRunning: mock(async () => true), + resetServerCheck: mock(() => {}), + markServerRunningInProcess: mock(() => {}), + getPaneDimensions: mock(async () => ({ width: 220, height: 44 })), + spawnTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + closeTmuxPane: mock(async () => ({ success: true })), + replaceTmuxPane: mock(async () => ({ success: true, paneId: "%1" })), + spawnTmuxWindow: mock(async () => ({ success: true, windowId: "@1" })), + spawnTmuxSession: mock(async () => ({ success: true, sessionId: "mock" })), + applyLayout: mock(async () => ({ success: true })), + enforceMainPaneWidth: mock(async () => ({ success: true })), POLL_INTERVAL_BACKGROUND_MS: 10, SESSION_READY_POLL_INTERVAL_MS: 10, SESSION_READY_TIMEOUT_MS: 50, SESSION_MISSING_GRACE_MS: 1_000, + SESSION_TIMEOUT_MS: 600_000, })) afterAll(() => { mock.restore() }) From fbd3e7aabed2bf8e091e6b3999c3908fd038718a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Apr 2026 10:04:19 +0000 Subject: [PATCH 76/86] release: v3.16.0 --- package.json | 2 +- packages/darwin-arm64/package.json | 2 +- packages/darwin-x64-baseline/package.json | 2 +- packages/darwin-x64/package.json | 2 +- packages/linux-arm64-musl/package.json | 2 +- packages/linux-arm64/package.json | 2 +- packages/linux-x64-baseline/package.json | 2 +- packages/linux-x64-musl-baseline/package.json | 2 +- packages/linux-x64-musl/package.json | 2 +- packages/linux-x64/package.json | 2 +- packages/windows-x64-baseline/package.json | 2 +- packages/windows-x64/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index f6b24b13a..f85be03a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode", - "version": "3.15.3", + "version": "3.16.0", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "main": "./dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json index f14b36ad8..bea7332ba 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-arm64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64-baseline/package.json b/packages/darwin-x64-baseline/package.json index 8cb655c1f..2dc63284a 100644 --- a/packages/darwin-x64-baseline/package.json +++ b/packages/darwin-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json index ebe5fb016..ca47167b4 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64-musl/package.json b/packages/linux-arm64-musl/package.json index 9db05776c..b69025a4e 100644 --- a/packages/linux-arm64-musl/package.json +++ b/packages/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64-musl", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json index ba10cc22f..d1ed90d58 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-baseline/package.json b/packages/linux-x64-baseline/package.json index c9a321390..210556ed2 100644 --- a/packages/linux-x64-baseline/package.json +++ b/packages/linux-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl-baseline/package.json b/packages/linux-x64-musl-baseline/package.json index c4f5e9b81..fc4871a58 100644 --- a/packages/linux-x64-musl-baseline/package.json +++ b/packages/linux-x64-musl-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl/package.json b/packages/linux-x64-musl/package.json index c720cb548..98b6c63a0 100644 --- a/packages/linux-x64-musl/package.json +++ b/packages/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json index be62e8c92..155a12524 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64)", "license": "MIT", "repository": { diff --git a/packages/windows-x64-baseline/package.json b/packages/windows-x64-baseline/package.json index f77ff07a5..253fccf2d 100644 --- a/packages/windows-x64-baseline/package.json +++ b/packages/windows-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64-baseline", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/windows-x64/package.json b/packages/windows-x64/package.json index 25e1d7612..a3ef0824a 100644 --- a/packages/windows-x64/package.json +++ b/packages/windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64", - "version": "3.15.3", + "version": "3.16.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64)", "license": "MIT", "repository": { From 686f903d1f71f83238196979c38a7897153413a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:52:51 +0000 Subject: [PATCH 77/86] @FrancoStino has signed the CLA in code-yeongyu/oh-my-openagent#3234 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 3d0f801c1..e8a1a143b 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2623,6 +2623,14 @@ "created_at": "2026-04-08T05:40:40Z", "repoId": 1108837393, "pullRequestNo": 3217 + }, + { + "name": "FrancoStino", + "id": 32127923, + "comment_id": 4205715582, + "created_at": "2026-04-08T10:52:39Z", + "repoId": 1108837393, + "pullRequestNo": 3234 } ] } \ No newline at end of file From 1ea0ee4319e3129183749fc7bdb96ec46fa35fbe Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 20:10:25 +0900 Subject: [PATCH 78/86] fix(preemptive-compaction): notify user on failure and reduce timeout Reports from david_66 on Discord: sessions get perceived as 'stuck' when context usage crosses the 78% threshold. Investigation confirmed two issues in the preemptive compaction hook: 1. PREEMPTIVE_COMPACTION_TIMEOUT_MS was 120s. While the summarize request is in flight, tool.execute.after short-circuits via the compactionInProgress guard. A hung summarize blocked the session for two full minutes before giving up, which users reasonably experience as a hang. 2. On failure (timeout or exception) only a log line was emitted. The user had no visibility into why their session was unresponsive or why auto-compaction never ran, so a transient upstream error could silently leave them well above the threshold with no signal. Fix: - Reduce timeout 120s -> 60s. Still gives the upstream a generous window, but caps the worst-case perceived hang at one minute. - Show a warning toast via ctx.client.tui.showToast whenever the catch block fires, including the underlying error string so users can act (retry, manual /compact, or adjust provider). - Include providerID/modelID in the Compaction failed log entry so wild failures are easier to correlate to a specific target model. Two existing failure-path assertions were updated to match the new log shape and a new test covers the toast notification contract. Discord report: https://discord.com/channels/1452487457085063218/1490536332961906829/1491345441399505037 --- src/hooks/preemptive-compaction.test.ts | 49 +++++++++++++++++++++++++ src/hooks/preemptive-compaction.ts | 22 ++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index ef6e695b0..09cbf83dc 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -284,10 +284,57 @@ describe("preemptive-compaction", () => { //#then expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", { sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", error: String(summarizeError), }) }) + // #given compaction fails + // #when tool.execute.after completes the catch block + // #then should show a warning toast explaining the failure to the user + it("should show a warning toast when preemptive compaction fails", async () => { + //#given + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_toast_on_failure" + const summarizeError = new Error("upstream rate limited") + ctx.client.session.summarize.mockRejectedValueOnce(summarizeError) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + //#when + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_toast" }, + { title: "", output: "test", metadata: null }, + ) + + //#then + expect(ctx.client.tui.showToast).toHaveBeenCalledTimes(1) + const toastCall = ctx.client.tui.showToast.mock.calls[0]?.[0] + expect(toastCall?.body?.title).toBe("Preemptive compaction failed") + expect(toastCall?.body?.variant).toBe("warning") + expect(String(toastCall?.body?.message)).toContain("upstream rate limited") + }) + // #given compaction fails // #when tool.execute.after is called again immediately // #then should NOT retry due to cooldown @@ -475,6 +522,8 @@ describe("preemptive-compaction", () => { expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) expect(logMock).toHaveBeenCalledWith("[preemptive-compaction] Compaction failed", { sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", error: expect.stringContaining("Compaction summarize timed out"), }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index ef58b1a95..ecab70676 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -8,7 +8,7 @@ import { import { resolveCompactionModel } from "./shared/compaction-model-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" -const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 +const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000 const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000 @@ -134,7 +134,25 @@ export function createPreemptiveCompactionHook( compactedSessions.add(sessionID) } catch (error) { - log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) }) + log("[preemptive-compaction] Compaction failed", { + sessionID, + providerID: cached.providerID, + modelID: cached.modelID, + error: String(error), + }) + ctx.client.tui.showToast({ + body: { + title: "Preemptive compaction failed", + message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`, + variant: "warning", + duration: 10000, + }, + }).catch((toastError: unknown) => { + log("[preemptive-compaction] Failed to show toast", { + sessionID, + toastError: String(toastError), + }) + }) } finally { compactionInProgress.delete(sessionID) } From 47283f92385eb4f016c76254313f9b4fef465871 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 22:51:59 +0900 Subject: [PATCH 79/86] fix(agents): remove ZWSP prefixes from config.agent keys (#3238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent names in the config.agent object (which becomes the /agent API response) contained invisible Zero-Width Space (U+200B) characters baked in by getAgentListDisplayName(). These ZWSP prefixes were used for TUI sort ordering, but they leaked into the public API surface. Impact: any prompt_async consumer that discovered agent names via the /agent endpoint and passed them back to prompt_async without manual ZWSP stripping got silent message drops — the agent name didn't match. hy-pony's feishu-bridge integration went dark after upgrading to 3.16.0 with no error, no warning, and no indication that invisible Unicode characters in agent names were the cause. Fix: switch all four callsites from getAgentListDisplayName() (which prepends \u200B×N) to getAgentDisplayName() (clean names): - agent-key-remapper.ts: config keys → display names (was the primary injection point) - agent-priority-order.ts: CORE_AGENT_ORDER lookup (must agree with the keys emitted by the remapper) - command-config-handler.ts: command agent field normalization - tool-config-handler.ts: agent config lookup (simplified fallback chain since the primary lookup is now clean) Sort ordering is preserved by: 1. JS object insertion order from reorderAgentsByPriority() 2. The injected `order` field (1-4) added by injectOrderField() getAgentListDisplayName() is marked @deprecated with a link to #3238. AGENT_LIST_SORT_PREFIXES and stripAgentListSortPrefix() are kept for any internal callers that strip prefixes from legacy data. Closes #3238 --- .../agent-config-handler.test.ts | 4 +- .../agent-key-remapper.test.ts | 24 ++++---- src/plugin-handlers/agent-key-remapper.ts | 4 +- .../agent-priority-order.test.ts | 16 ++--- src/plugin-handlers/agent-priority-order.ts | 10 ++-- .../command-config-handler.test.ts | 6 +- src/plugin-handlers/command-config-handler.ts | 4 +- src/plugin-handlers/config-handler.test.ts | 60 +++++++++---------- src/plugin-handlers/tool-config-handler.ts | 4 +- src/shared/agent-display-names.ts | 7 +++ 10 files changed, 73 insertions(+), 66 deletions(-) diff --git a/src/plugin-handlers/agent-config-handler.test.ts b/src/plugin-handlers/agent-config-handler.test.ts index c29a3245d..c557b7955 100644 --- a/src/plugin-handlers/agent-config-handler.test.ts +++ b/src/plugin-handlers/agent-config-handler.test.ts @@ -9,11 +9,11 @@ import type { OhMyOpenCodeConfig } from "../config" import * as agentLoader from "../features/claude-code-agent-loader" import * as skillLoader from "../features/opencode-skill-loader" import type { LoadedSkill } from "../features/opencode-skill-loader" -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName, getAgentDisplayName } from "../shared/agent-display-names" import { applyAgentConfig } from "./agent-config-handler" import type { PluginComponents } from "./plugin-components-loader" -const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentListDisplayName("sisyphus") +const BUILTIN_SISYPHUS_DISPLAY_NAME = getAgentDisplayName("sisyphus") const BUILTIN_SISYPHUS_JUNIOR_DISPLAY_NAME = getAgentDisplayName("sisyphus-junior") const BUILTIN_MULTIMODAL_LOOKER_DISPLAY_NAME = getAgentDisplayName("multimodal-looker") diff --git a/src/plugin-handlers/agent-key-remapper.test.ts b/src/plugin-handlers/agent-key-remapper.test.ts index 81d41c69e..3b14781c6 100644 --- a/src/plugin-handlers/agent-key-remapper.test.ts +++ b/src/plugin-handlers/agent-key-remapper.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test" import { remapAgentKeysToDisplayNames } from "./agent-key-remapper" -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName } from "../shared/agent-display-names" describe("remapAgentKeysToDisplayNames", () => { it("remaps known agent keys to display names", () => { @@ -14,7 +14,7 @@ describe("remapAgentKeysToDisplayNames", () => { const result = remapAgentKeysToDisplayNames(agents) // then known agents get display name keys only - expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined() + expect(result[getAgentDisplayName("sisyphus")]).toBeDefined() expect(result["oracle"]).toBeDefined() expect(result["sisyphus"]).toBeUndefined() }) @@ -49,21 +49,21 @@ describe("remapAgentKeysToDisplayNames", () => { const result = remapAgentKeysToDisplayNames(agents) // then all get display name keys - expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined() + expect(result[getAgentDisplayName("sisyphus")]).toBeDefined() expect(result["sisyphus"]).toBeUndefined() - expect(result[getAgentListDisplayName("hephaestus")]).toBeDefined() + expect(result[getAgentDisplayName("hephaestus")]).toBeDefined() expect(result["hephaestus"]).toBeUndefined() - expect(result[getAgentListDisplayName("prometheus")]).toBeDefined() + expect(result[getAgentDisplayName("prometheus")]).toBeDefined() expect(result["prometheus"]).toBeUndefined() - expect(result[getAgentListDisplayName("atlas")]).toBeDefined() + expect(result[getAgentDisplayName("atlas")]).toBeDefined() expect(result["atlas"]).toBeUndefined() - expect(result[getAgentListDisplayName("athena")]).toBeDefined() + expect(result[getAgentDisplayName("athena")]).toBeDefined() expect(result["athena"]).toBeUndefined() - expect(result[getAgentListDisplayName("metis")]).toBeDefined() + expect(result[getAgentDisplayName("metis")]).toBeDefined() expect(result["metis"]).toBeUndefined() - expect(result[getAgentListDisplayName("momus")]).toBeDefined() + expect(result[getAgentDisplayName("momus")]).toBeDefined() expect(result["momus"]).toBeUndefined() - expect(result[getAgentListDisplayName("sisyphus-junior")]).toBeDefined() + expect(result[getAgentDisplayName("sisyphus-junior")]).toBeDefined() expect(result["sisyphus-junior"]).toBeUndefined() }) @@ -77,8 +77,8 @@ describe("remapAgentKeysToDisplayNames", () => { const result = remapAgentKeysToDisplayNames(agents) // then only display key is emitted - expect(Object.keys(result)).toEqual([getAgentListDisplayName("sisyphus")]) - expect(result[getAgentListDisplayName("sisyphus")]).toBeDefined() + expect(Object.keys(result)).toEqual([getAgentDisplayName("sisyphus")]) + expect(result[getAgentDisplayName("sisyphus")]).toBeDefined() expect(result["sisyphus"]).toBeUndefined() }) }) diff --git a/src/plugin-handlers/agent-key-remapper.ts b/src/plugin-handlers/agent-key-remapper.ts index 1becbcda9..54d422a4b 100644 --- a/src/plugin-handlers/agent-key-remapper.ts +++ b/src/plugin-handlers/agent-key-remapper.ts @@ -1,4 +1,4 @@ -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName } from "../shared/agent-display-names" export function remapAgentKeysToDisplayNames( agents: Record, @@ -6,7 +6,7 @@ export function remapAgentKeysToDisplayNames( const result: Record = {} for (const [key, value] of Object.entries(agents)) { - const displayName = getAgentListDisplayName(key) + const displayName = getAgentDisplayName(key) if (displayName && displayName !== key) { result[displayName] = value // Regression guard: do not also assign result[key]. diff --git a/src/plugin-handlers/agent-priority-order.test.ts b/src/plugin-handlers/agent-priority-order.test.ts index 2e48f0053..d28f6634a 100644 --- a/src/plugin-handlers/agent-priority-order.test.ts +++ b/src/plugin-handlers/agent-priority-order.test.ts @@ -1,16 +1,16 @@ import { describe, expect, test } from "bun:test" import { reorderAgentsByPriority } from "./agent-priority-order" -import { getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName } from "../shared/agent-display-names" describe("reorderAgentsByPriority", () => { test("moves core agents to canonical order and injects runtime order fields", () => { // given - const sisyphus = getAgentListDisplayName("sisyphus") - const hephaestus = getAgentListDisplayName("hephaestus") - const prometheus = getAgentListDisplayName("prometheus") - const atlas = getAgentListDisplayName("atlas") - const oracle = getAgentListDisplayName("oracle") + const sisyphus = getAgentDisplayName("sisyphus") + const hephaestus = getAgentDisplayName("hephaestus") + const prometheus = getAgentDisplayName("prometheus") + const atlas = getAgentDisplayName("atlas") + const oracle = getAgentDisplayName("oracle") const agents: Record = { [oracle]: { name: "oracle", mode: "subagent" }, @@ -59,8 +59,8 @@ describe("reorderAgentsByPriority", () => { test("leaves non-object agent configs untouched while still reordering keys", () => { // given - const sisyphus = getAgentListDisplayName("sisyphus") - const atlas = getAgentListDisplayName("atlas") + const sisyphus = getAgentDisplayName("sisyphus") + const atlas = getAgentDisplayName("atlas") const agents: Record = { [atlas]: "atlas-config", diff --git a/src/plugin-handlers/agent-priority-order.ts b/src/plugin-handlers/agent-priority-order.ts index f69b9a13b..c315ad76a 100644 --- a/src/plugin-handlers/agent-priority-order.ts +++ b/src/plugin-handlers/agent-priority-order.ts @@ -1,10 +1,10 @@ -import { getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; const CORE_AGENT_ORDER: ReadonlyArray<{ displayName: string; order: number }> = [ - { displayName: getAgentListDisplayName("sisyphus"), order: 1 }, - { displayName: getAgentListDisplayName("hephaestus"), order: 2 }, - { displayName: getAgentListDisplayName("prometheus"), order: 3 }, - { displayName: getAgentListDisplayName("atlas"), order: 4 }, + { displayName: getAgentDisplayName("sisyphus"), order: 1 }, + { displayName: getAgentDisplayName("hephaestus"), order: 2 }, + { displayName: getAgentDisplayName("prometheus"), order: 3 }, + { displayName: getAgentDisplayName("atlas"), order: 4 }, ]; function injectOrderField( diff --git a/src/plugin-handlers/command-config-handler.test.ts b/src/plugin-handlers/command-config-handler.test.ts index 41836dc6b..7a2c80ad4 100644 --- a/src/plugin-handlers/command-config-handler.test.ts +++ b/src/plugin-handlers/command-config-handler.test.ts @@ -7,7 +7,7 @@ import type { PluginComponents } from "./plugin-components-loader"; import { applyCommandConfig } from "./command-config-handler"; import { getAgentDisplayName, - getAgentListDisplayName, + getAgentDisplayName, } from "../shared/agent-display-names"; function createPluginComponents(): PluginComponents { @@ -122,7 +122,7 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); }); test("normalizes legacy display-name command agents to the exported list key", async () => { @@ -147,6 +147,6 @@ describe("applyCommandConfig", () => { // then const commandConfig = config.command as Record; - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")); + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")); }); }); diff --git a/src/plugin-handlers/command-config-handler.ts b/src/plugin-handlers/command-config-handler.ts index 471e4df52..86fdcfe26 100644 --- a/src/plugin-handlers/command-config-handler.ts +++ b/src/plugin-handlers/command-config-handler.ts @@ -1,7 +1,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { getAgentConfigKey, - getAgentListDisplayName, + getAgentDisplayName, } from "../shared/agent-display-names"; import { loadUserCommands, @@ -99,7 +99,7 @@ export async function applyCommandConfig(params: { function remapCommandAgentFields(commands: Record>): void { for (const cmd of Object.values(commands)) { if (cmd?.agent && typeof cmd.agent === "string") { - cmd.agent = getAgentListDisplayName(getAgentConfigKey(cmd.agent)); + cmd.agent = getAgentDisplayName(getAgentConfigKey(cmd.agent)); } } } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 1d9324f9e..3f1e58a88 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -4,7 +4,7 @@ import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test" import { resolveCategoryConfig, createConfigHandler } from "./config-handler" import type { CategoryConfig } from "../config/schema" import type { OhMyOpenCodeConfig } from "../config" -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names" +import { getAgentDisplayName, getAgentDisplayName } from "../shared/agent-display-names" import * as agents from "../agents" import * as sisyphusJunior from "../agents/sisyphus-junior" @@ -246,10 +246,10 @@ describe("Plan agent demote behavior", () => { // #then const keys = Object.keys(config.agent as Record) const coreAgents = [ - getAgentListDisplayName("sisyphus"), - getAgentListDisplayName("hephaestus"), - getAgentListDisplayName("prometheus"), - getAgentListDisplayName("atlas"), + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), ] const ordered = keys.filter((key) => coreAgents.includes(key)) expect(ordered).toEqual(coreAgents) @@ -294,10 +294,10 @@ describe("Plan agent demote behavior", () => { reorderSpy.mock.calls.at(0)?.[0] as Record ) expect(assembledAgentKeys.slice(0, 4)).toEqual([ - getAgentListDisplayName("sisyphus"), - getAgentListDisplayName("hephaestus"), - getAgentListDisplayName("prometheus"), - getAgentListDisplayName("atlas"), + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), ]) }) @@ -336,7 +336,7 @@ describe("Plan agent demote behavior", () => { expect(agents.plan).toBeDefined() expect(agents.plan.mode).toBe("subagent") expect(agents.plan.prompt).toBeUndefined() - expect(agents[getAgentListDisplayName("prometheus")]?.prompt).toBeDefined() + expect(agents[getAgentDisplayName("prometheus")]?.prompt).toBeDefined() }) test("plan agent remains unchanged when planner is disabled", async () => { @@ -370,7 +370,7 @@ describe("Plan agent demote behavior", () => { // #then - plan is not touched, prometheus is not created const agents = config.agent as Record - expect(agents[getAgentListDisplayName("prometheus")]).toBeUndefined() + expect(agents[getAgentDisplayName("prometheus")]).toBeUndefined() expect(agents.plan).toBeDefined() expect(agents.plan.mode).toBe("primary") expect(agents.plan.prompt).toBe("original plan prompt") @@ -401,7 +401,7 @@ describe("Plan agent demote behavior", () => { // then const agents = config.agent as Record - const prometheusKey = getAgentListDisplayName("prometheus") + const prometheusKey = getAgentDisplayName("prometheus") expect(agents[prometheusKey]).toBeDefined() expect(agents[prometheusKey].mode).toBe("all") }) @@ -437,7 +437,7 @@ describe("Agent permission defaults", () => { // #then const agentConfig = config.agent as Record }> - const hephaestusKey = getAgentListDisplayName("hephaestus") + const hephaestusKey = getAgentDisplayName("hephaestus") expect(agentConfig[hephaestusKey]).toBeDefined() expect(agentConfig[hephaestusKey].permission?.task).toBe("allow") }) @@ -779,7 +779,7 @@ describe("Prometheus direct override priority over category", () => { // then - direct override's reasoningEffort wins const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].reasoningEffort).toBe("low") }) @@ -820,7 +820,7 @@ describe("Prometheus direct override priority over category", () => { // then - category's reasoningEffort is applied const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].reasoningEffort).toBe("high") }) @@ -862,7 +862,7 @@ describe("Prometheus direct override priority over category", () => { // then - direct temperature wins over category const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].temperature).toBe(0.1) }) @@ -898,7 +898,7 @@ describe("Prometheus direct override priority over category", () => { // #then - prompt_append is appended to base prompt, not overwriting it const agents = config.agent as Record - const pKey = getAgentListDisplayName("prometheus") + const pKey = getAgentDisplayName("prometheus") expect(agents[pKey]).toBeDefined() expect(agents[pKey].prompt).toContain("Prometheus") expect(agents[pKey].prompt).toContain(customInstructions) @@ -1290,18 +1290,18 @@ describe("command agent routing coherence", () => { //#then const agentConfig = config.agent as Record const commandConfig = config.command as Record - expect(Object.keys(agentConfig)).toContain(getAgentListDisplayName("atlas")) - expect(commandConfig["start-work"]?.agent).toBe(getAgentListDisplayName("atlas")) + expect(Object.keys(agentConfig)).toContain(getAgentDisplayName("atlas")) + expect(commandConfig["start-work"]?.agent).toBe(getAgentDisplayName("atlas")) }) }) describe("per-agent todowrite/todoread deny when task_system enabled", () => { const AGENTS_WITH_TODO_DENY = new Set([ - getAgentListDisplayName("sisyphus"), - getAgentListDisplayName("hephaestus"), - getAgentListDisplayName("prometheus"), - getAgentListDisplayName("atlas"), - getAgentListDisplayName("sisyphus-junior"), + getAgentDisplayName("sisyphus"), + getAgentDisplayName("hephaestus"), + getAgentDisplayName("prometheus"), + getAgentDisplayName("atlas"), + getAgentDisplayName("sisyphus-junior"), ]) test("denies todowrite and todoread for primary agents when task_system is enabled", async () => { @@ -1381,10 +1381,10 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { expect(lastCall?.[11]).toBe(false) const agentResult = config.agent as Record }> - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() - expect(agentResult[getAgentListDisplayName("hephaestus")]?.permission?.todowrite).toBeUndefined() - expect(agentResult[getAgentListDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined() }) test("does not deny todowrite/todoread when task_system is undefined", async () => { @@ -1420,8 +1420,8 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => { expect(lastCall?.[11]).toBe(false) const agentResult = config.agent as Record }> - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() - expect(agentResult[getAgentListDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() + expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() }) }) diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index dae34fda6..d698e9560 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -1,5 +1,5 @@ import type { OhMyOpenCodeConfig } from "../config"; -import { getAgentDisplayName, getAgentListDisplayName } from "../shared/agent-display-names"; +import { getAgentDisplayName } from "../shared/agent-display-names"; import { isTaskSystemEnabled } from "../shared"; type AgentWithPermission = { permission?: Record }; @@ -16,7 +16,7 @@ function getConfigQuestionPermission(): string | null { } function agentByKey(agentResult: Record, key: string): AgentWithPermission | undefined { - return (agentResult[getAgentListDisplayName(key)] ?? agentResult[getAgentDisplayName(key)] ?? agentResult[key]) as + return (agentResult[getAgentDisplayName(key)] ?? agentResult[key]) as | AgentWithPermission | undefined; } diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index 9841074e4..426425851 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -57,6 +57,13 @@ export function getAgentDisplayName(configKey: string): string { return configKey } +/** + * @deprecated Do NOT use for config.agent keys or API-facing names. + * ZWSP prefixes leak into the /agent API response and break prompt_async consumers. + * Use getAgentDisplayName() instead. The `order` field injected by + * reorderAgentsByPriority() handles sort ordering without invisible characters. + * See: https://github.com/code-yeongyu/oh-my-openagent/issues/3238 + */ export function getAgentListDisplayName(configKey: string): string { const displayName = getAgentDisplayName(configKey) const prefix = AGENT_LIST_SORT_PREFIXES[configKey.toLowerCase()] From 6943b6d4d8b631c0290a31c95c8f0d999902c68e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 8 Apr 2026 23:39:43 +0900 Subject: [PATCH 80/86] fix(ci): isolate model-resolver and prometheus-config tests from mock.module contamination model-resolver.test.ts and prometheus-agent-config-builder.test.ts use spyOn(shared, 'log') but do not own the logger module. When other test files in the same bun test process call mock.module('../shared/logger'), the import cache is poisoned and the spyOn targets a stale binding. Add a lightweight mock.module call at the top of each file so the auto-detection in run-ci-tests.ts picks them up as isolated targets. This ensures each file gets its own module instance and the spy captures all calls correctly. Fixes the flaky CI failure pattern where resolveModelWithFallback and buildPrometheusAgentConfig tests pass locally (separate bun process) but fail in the shared CI batch. --- src/plugin-handlers/prometheus-agent-config-builder.test.ts | 6 +++++- src/shared/model-resolver.test.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/plugin-handlers/prometheus-agent-config-builder.test.ts b/src/plugin-handlers/prometheus-agent-config-builder.test.ts index e6440834a..e01403289 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.test.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test, spyOn, afterEach, beforeEach } from "bun:test"; +import { describe, expect, test, spyOn, afterEach, beforeEach, mock } from "bun:test"; + +// Isolate from other tests that mock.module the logger (CI cross-contamination fix) +mock.module("../shared/logger", () => ({ log: (..._args: unknown[]) => {} })) + import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import * as shared from "../shared"; import * as categoryResolver from "./category-config-resolver"; diff --git a/src/shared/model-resolver.test.ts b/src/shared/model-resolver.test.ts index 23a02c132..292aac718 100644 --- a/src/shared/model-resolver.test.ts +++ b/src/shared/model-resolver.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test, spyOn, beforeEach, afterEach, mock } from "bun:test" + +// Isolate from other tests that mock.module the logger (CI cross-contamination fix) +mock.module("./logger", () => ({ log: (..._args: unknown[]) => {} })) + import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver" import * as logger from "./logger" import * as connectedProvidersCache from "./connected-providers-cache" From fcac67d1d20e3bfbddd275d0eb5c03b6fa4dd888 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:57:18 +0000 Subject: [PATCH 81/86] @sen7971 has signed the CLA in code-yeongyu/oh-my-openagent#3248 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index e8a1a143b..4dd237280 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2631,6 +2631,14 @@ "created_at": "2026-04-08T10:52:39Z", "repoId": 1108837393, "pullRequestNo": 3234 + }, + { + "name": "sen7971", + "id": 193416996, + "comment_id": 4207621925, + "created_at": "2026-04-08T15:57:15Z", + "repoId": 1108837393, + "pullRequestNo": 3248 } ] } \ No newline at end of file From 4344a41eae5a57460b837a5e4c16c1be1749c4b0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 07:27:50 +0900 Subject: [PATCH 82/86] fix(auto-update): resolve cached version when installed as oh-my-openagent (#3257) The auto-update-checker's version resolver hardcoded the canonical oh-my-opencode package name in three read paths: 1. INSTALLED_PACKAGE_JSON pointed only at cache/node_modules/oh-my-opencode/package.json 2. findPackageJsonUp() rejected any walked-up package.json whose name did not equal PACKAGE_NAME 3. getLocalDevPath() only matched file:// plugin entries whose path contained the canonical name The publish pipeline ships the same code under two npm package names (oh-my-opencode canonical, oh-my-openagent alias). Users who add "oh-my-openagent" to their opencode config end up with node_modules/oh-my-openagent/package.json, so every read path above silently missed the installed version and the startup toast fell back to "unknown". Introduce ACCEPTED_PACKAGE_NAMES + INSTALLED_PACKAGE_JSON_CANDIDATES in constants.ts and teach the three readers to accept both names. Writes are untouched (sync-package-json, pinned-version-updater, cache invalidation) because the auto-update-checker still owns its own cache workspace and writes to the canonical name there. Tests: 54 auto-update-checker tests pass (4 new), full 4444-test suite passes, tsc clean. New tests cover both install paths, the walk-up resolver, and the priority order when both candidates exist. Closes #3257 --- .../checker/cached-version.test.ts | 80 +++++++++++++++++++ .../checker/cached-version.ts | 18 +++-- .../checker/local-dev-path.ts | 14 ++-- .../checker/package-json-locator.test.ts | 65 +++++++++++++++ .../checker/package-json-locator.ts | 6 +- .../auto-update-checker/constants.test.ts | 20 +++++ src/hooks/auto-update-checker/constants.ts | 18 +++++ 7 files changed, 204 insertions(+), 17 deletions(-) create mode 100644 src/hooks/auto-update-checker/checker/cached-version.test.ts create mode 100644 src/hooks/auto-update-checker/checker/package-json-locator.test.ts diff --git a/src/hooks/auto-update-checker/checker/cached-version.test.ts b/src/hooks/auto-update-checker/checker/cached-version.test.ts new file mode 100644 index 000000000..6a6790134 --- /dev/null +++ b/src/hooks/auto-update-checker/checker/cached-version.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +// Hold mutable mock state so beforeEach can swap the cache root for each test. +const mockState: { candidates: string[] } = { candidates: [] } + +mock.module("../constants", () => ({ + INSTALLED_PACKAGE_JSON_CANDIDATES: new Proxy([], { + get(_, prop) { + const current = mockState.candidates + // Forward array methods/properties to the mutable candidates list + // so getCachedVersion's `for (... of ...)` sees fresh data per test. + const value = (current as unknown as Record)[prop] + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(current) + } + return value + }, + }), +})) + +mock.module("./package-json-locator", () => ({ + findPackageJsonUp: () => null, +})) + +import { getCachedVersion } from "./cached-version" + +describe("getCachedVersion (GH-3257)", () => { + let cacheRoot: string + + beforeEach(() => { + cacheRoot = mkdtempSync(join(tmpdir(), "omo-cached-version-")) + mockState.candidates = [ + join(cacheRoot, "node_modules", "oh-my-opencode", "package.json"), + join(cacheRoot, "node_modules", "oh-my-openagent", "package.json"), + ] + }) + + afterEach(() => { + rmSync(cacheRoot, { recursive: true, force: true }) + mockState.candidates = [] + }) + + it("returns the version when the package is installed under oh-my-opencode", () => { + const pkgDir = join(cacheRoot, "node_modules", "oh-my-opencode") + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" })) + + expect(getCachedVersion()).toBe("3.16.0") + }) + + it("returns the version when the package is installed under oh-my-openagent", () => { + // GH-3257: npm users who install the aliased `oh-my-openagent` package get + // node_modules/oh-my-openagent/package.json, not the canonical oh-my-opencode + // path. The cached version resolver must check both. + const pkgDir = join(cacheRoot, "node_modules", "oh-my-openagent") + mkdirSync(pkgDir, { recursive: true }) + writeFileSync(join(pkgDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" })) + + expect(getCachedVersion()).toBe("3.16.0") + }) + + it("prefers oh-my-opencode when both are installed", () => { + const legacyDir = join(cacheRoot, "node_modules", "oh-my-opencode") + mkdirSync(legacyDir, { recursive: true }) + writeFileSync(join(legacyDir, "package.json"), JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" })) + + const aliasDir = join(cacheRoot, "node_modules", "oh-my-openagent") + mkdirSync(aliasDir, { recursive: true }) + writeFileSync(join(aliasDir, "package.json"), JSON.stringify({ name: "oh-my-openagent", version: "3.15.0" })) + + expect(getCachedVersion()).toBe("3.16.0") + }) + + it("returns null when neither candidate exists and fallbacks find nothing", () => { + expect(getCachedVersion()).toBeNull() + }) +}) diff --git a/src/hooks/auto-update-checker/checker/cached-version.ts b/src/hooks/auto-update-checker/checker/cached-version.ts index 15aef4eff..0041122c3 100644 --- a/src/hooks/auto-update-checker/checker/cached-version.ts +++ b/src/hooks/auto-update-checker/checker/cached-version.ts @@ -3,18 +3,20 @@ import * as path from "node:path" import { fileURLToPath } from "node:url" import { log } from "../../../shared/logger" import type { PackageJson } from "../types" -import { INSTALLED_PACKAGE_JSON } from "../constants" +import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants" import { findPackageJsonUp } from "./package-json-locator" export function getCachedVersion(): string | null { - try { - if (fs.existsSync(INSTALLED_PACKAGE_JSON)) { - const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8") - const pkg = JSON.parse(content) as PackageJson - if (pkg.version) return pkg.version + for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) { + try { + if (fs.existsSync(candidate)) { + const content = fs.readFileSync(candidate, "utf-8") + const pkg = JSON.parse(content) as PackageJson + if (pkg.version) return pkg.version + } + } catch { + // ignore; try next candidate } - } catch { - // ignore } try { diff --git a/src/hooks/auto-update-checker/checker/local-dev-path.ts b/src/hooks/auto-update-checker/checker/local-dev-path.ts index 5bf1e5ced..e9c820617 100644 --- a/src/hooks/auto-update-checker/checker/local-dev-path.ts +++ b/src/hooks/auto-update-checker/checker/local-dev-path.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs" import { fileURLToPath } from "node:url" import type { OpencodeConfig } from "../types" -import { PACKAGE_NAME } from "../constants" +import { ACCEPTED_PACKAGE_NAMES } from "../constants" import { getConfigPaths } from "./config-paths" import { stripJsonComments } from "./jsonc-strip" @@ -18,12 +18,12 @@ export function getLocalDevPath(directory: string): string | null { const plugins = config.plugin ?? [] for (const entry of plugins) { - if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) { - try { - return fileURLToPath(entry) - } catch { - return entry.replace("file://", "") - } + if (!entry.startsWith("file://")) continue + if (!ACCEPTED_PACKAGE_NAMES.some(name => entry.includes(name))) continue + try { + return fileURLToPath(entry) + } catch { + return entry.replace("file://", "") } } } catch { diff --git a/src/hooks/auto-update-checker/checker/package-json-locator.test.ts b/src/hooks/auto-update-checker/checker/package-json-locator.test.ts new file mode 100644 index 000000000..da04eeebd --- /dev/null +++ b/src/hooks/auto-update-checker/checker/package-json-locator.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { findPackageJsonUp } from "./package-json-locator" + +describe("findPackageJsonUp", () => { + let workdir: string + + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "omo-pkg-locator-")) + }) + + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + it("finds a package.json whose name is the canonical oh-my-opencode", () => { + const pkgPath = join(workdir, "package.json") + writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-opencode", version: "3.16.0" })) + + const found = findPackageJsonUp(workdir) + + expect(found).toBe(pkgPath) + }) + + it("finds a package.json whose name is the aliased oh-my-openagent (GH-3257)", () => { + // A user who installed `oh-my-openagent` from npm gets a node_modules entry + // whose package.json has `name: "oh-my-openagent"`. The auto-update-checker + // must still resolve it so the startup toast shows a real version instead + // of "unknown". + const pkgPath = join(workdir, "package.json") + writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" })) + + const found = findPackageJsonUp(workdir) + + expect(found).toBe(pkgPath) + }) + + it("walks up directories to find the matching package.json", () => { + const nested = join(workdir, "dist", "checker") + mkdirSync(nested, { recursive: true }) + const pkgPath = join(workdir, "package.json") + writeFileSync(pkgPath, JSON.stringify({ name: "oh-my-openagent", version: "3.16.0" })) + + const found = findPackageJsonUp(nested) + + expect(found).toBe(pkgPath) + }) + + it("ignores unrelated package.json files", () => { + const pkgPath = join(workdir, "package.json") + writeFileSync(pkgPath, JSON.stringify({ name: "some-other-package", version: "1.0.0" })) + + const found = findPackageJsonUp(workdir) + + expect(found).toBeNull() + }) + + it("returns null when no package.json exists", () => { + const found = findPackageJsonUp(workdir) + + expect(found).toBeNull() + }) +}) diff --git a/src/hooks/auto-update-checker/checker/package-json-locator.ts b/src/hooks/auto-update-checker/checker/package-json-locator.ts index 308cad163..9887ef1c8 100644 --- a/src/hooks/auto-update-checker/checker/package-json-locator.ts +++ b/src/hooks/auto-update-checker/checker/package-json-locator.ts @@ -1,7 +1,9 @@ import * as fs from "node:fs" import * as path from "node:path" import type { PackageJson } from "../types" -import { PACKAGE_NAME } from "../constants" +import { ACCEPTED_PACKAGE_NAMES } from "../constants" + +const ACCEPTED_NAME_SET = new Set(ACCEPTED_PACKAGE_NAMES) export function findPackageJsonUp(startPath: string): string | null { try { @@ -14,7 +16,7 @@ export function findPackageJsonUp(startPath: string): string | null { try { const content = fs.readFileSync(pkgPath, "utf-8") const pkg = JSON.parse(content) as PackageJson - if (pkg.name === PACKAGE_NAME) return pkgPath + if (pkg.name && ACCEPTED_NAME_SET.has(pkg.name)) return pkgPath } catch { // ignore } diff --git a/src/hooks/auto-update-checker/constants.test.ts b/src/hooks/auto-update-checker/constants.test.ts index bc9fcbc26..cc0ea44c8 100644 --- a/src/hooks/auto-update-checker/constants.test.ts +++ b/src/hooks/auto-update-checker/constants.test.ts @@ -26,4 +26,24 @@ describe("auto-update-checker constants", () => { // then PACKAGE_NAME equals the actually published package name expect(PACKAGE_NAME).toBe(repoPackageJson.name) }) + + it("ACCEPTED_PACKAGE_NAMES contains both the canonical and aliased npm names (GH-3257)", async () => { + const { ACCEPTED_PACKAGE_NAMES } = await import(`./constants?test=${Date.now()}`) + + expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-opencode") + expect(ACCEPTED_PACKAGE_NAMES).toContain("oh-my-openagent") + }) + + it("INSTALLED_PACKAGE_JSON_CANDIDATES covers every accepted package name (GH-3257)", async () => { + const { ACCEPTED_PACKAGE_NAMES, INSTALLED_PACKAGE_JSON_CANDIDATES, CACHE_DIR } = await import( + `./constants?test=${Date.now()}` + ) + + expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toHaveLength(ACCEPTED_PACKAGE_NAMES.length) + for (const name of ACCEPTED_PACKAGE_NAMES) { + expect(INSTALLED_PACKAGE_JSON_CANDIDATES).toContain( + join(CACHE_DIR, "node_modules", name, "package.json") + ) + } + }) }) diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts index 9a40ecfb4..9de9fb6a0 100644 --- a/src/hooks/auto-update-checker/constants.ts +++ b/src/hooks/auto-update-checker/constants.ts @@ -4,6 +4,16 @@ import { getOpenCodeCacheDir } from "../../shared/data-path" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" export const PACKAGE_NAME = "oh-my-opencode" +/** + * All package names the canonical plugin may be published under. + * + * The package is published to npm as both `oh-my-opencode` (legacy canonical) + * and `oh-my-openagent` (current canonical). Any code that *reads* an + * installed package.json or walks up from an import path must accept both, + * because the installed name depends on which package the user added to + * their config. Code that *writes* continues to use {@link PACKAGE_NAME}. + */ +export const ACCEPTED_PACKAGE_NAMES = ["oh-my-opencode", "oh-my-openagent"] as const export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags` export const NPM_FETCH_TIMEOUT = 5000 @@ -34,3 +44,11 @@ export const INSTALLED_PACKAGE_JSON = path.join( PACKAGE_NAME, "package.json" ) + +/** + * Candidate paths where the installed package.json may live, in priority order. + * Readers should try each path in order and stop on the first success. + */ +export const INSTALLED_PACKAGE_JSON_CANDIDATES = ACCEPTED_PACKAGE_NAMES.map( + name => path.join(CACHE_DIR, "node_modules", name, "package.json") +) From 8b418ea38a8ebfd48334001b48f5bd33a2a18ac7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 10:10:12 +0900 Subject: [PATCH 83/86] fix(agents): remove ZWSP sort prefixes from display name helper (#3259) AGENT_LIST_SORT_PREFIXES prepended U+200B Zero Width Space characters to the four core agent display names so they would sort ahead of user agents in the Tab cycle. Two problems with that approach surfaced: 1. Some terminal emulators (Ghostty, certain Windows Terminal builds) render ZWSP as a visible box or extra space, producing a visible black gap in the status bar before "Sisyphus" and misaligning the layout (#3259). 2. The prefixes leaked into the plugin API surface via config.agent keys, breaking prompt_async consumers that received ZWSP-contaminated agent names (#3238). #3242 already removed every call site of getAgentListDisplayName() in production code. That made the sort prefixes dead code: the constant table was still defined but nothing read it. This PR finishes the cleanup by: - Deleting the AGENT_LIST_SORT_PREFIXES constant entirely - Turning getAgentListDisplayName() into a thin alias over getAgentDisplayName() for BC with external importers - Keeping stripAgentListSortPrefix() as a legacy data migration for users upgrading from v3.14.0-v3.16.0 whose config.agent keys may still have ZWSP baked in from the old code path - Documenting the history on stripAgentListSortPrefix() so future maintainers understand why the stripper has to stay even after the injector is gone Sort ordering is preserved via JS object insertion order in reorderAgentsByPriority() plus the `order` field it injects on the four core agents. Both mechanisms are already in place and both pre-date this PR; the ZWSP prefix was an older third layer that was only meant to work around alphabetical sorting in legacy OpenCode before the `order` field landed upstream. Tests: 4445 pass, 0 fail. Added 3 new assertions to agent-display-names.test.ts verifying that getAgentListDisplayName returns plain names containing no zero-width characters. Updated chat-message.test.ts to use a literal ZWSP string instead of the helper so the defensive-strip path still has coverage. Closes #3259 --- src/plugin-interface.test.ts | 1 - src/plugin/chat-message.test.ts | 8 +++-- src/shared/agent-display-names.test.ts | 44 +++++++++++++++++-------- src/shared/agent-display-names.ts | 45 +++++++++++++++++--------- 4 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/plugin-interface.test.ts b/src/plugin-interface.test.ts index 4dac3f7be..fea4752e2 100644 --- a/src/plugin-interface.test.ts +++ b/src/plugin-interface.test.ts @@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto" import { createPluginInterface } from "./plugin-interface" import { createAutoSlashCommandHook } from "./hooks/auto-slash-command" import { createStartWorkHook } from "./hooks/start-work" -import { getAgentListDisplayName } from "./shared/agent-display-names" import { readBoulderState } from "./features/boulder-state" import { _resetForTesting, diff --git a/src/plugin/chat-message.test.ts b/src/plugin/chat-message.test.ts index 6dd7a0397..2d96f6065 100644 --- a/src/plugin/chat-message.test.ts +++ b/src/plugin/chat-message.test.ts @@ -9,7 +9,6 @@ import { createAutoSlashCommandHook } from "../hooks/auto-slash-command" import { createStartWorkHook } from "../hooks/start-work" import { readBoulderState } from "../features/boulder-state" import { _resetForTesting, setMainSession, subagentSessions, registerAgentName, updateSessionAgent, getSessionAgent } from "../features/claude-code-session-state" -import { getAgentListDisplayName } from "../shared/agent-display-names" import { clearSessionModel, getSessionModel, setSessionModel } from "../shared/session-model-state" type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } @@ -403,7 +402,10 @@ describe("createChatMessageHandler - TUI variant passthrough", () => { expect(getSessionModel("test-session")).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) }) - test("treats prefixed list-display agent names as explicit model overrides", async () => { + test("treats legacy ZWSP-prefixed agent names as explicit model overrides (GH-3259)", async () => { + // Users upgrading from v3.14.0-v3.16.0 may still have ZWSP-prefixed agent + // keys persisted in their session state. The handler must strip the + // prefix and resolve to the canonical display name. //#given setMainSession("test-session") setSessionModel("test-session", { providerID: "openai", modelID: "gpt-5.4" }) @@ -416,7 +418,7 @@ describe("createChatMessageHandler - TUI variant passthrough", () => { }, }) const handler = createChatMessageHandler(args) - const input = createMockInput(getAgentListDisplayName("prometheus")) + const input = createMockInput("\u200B\u200B\u200BPrometheus - Plan Builder") const output = createMockOutput() //#when diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 353bfb31e..b77a5e1ff 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -183,30 +183,46 @@ describe("getAgentConfigKey", () => { expect(getAgentConfigKey("Sisyphus-Junior")).toBe("sisyphus-junior") }) - it("resolves atlas even when the UI ordering prefix is present", () => { - expect(getAgentConfigKey(getAgentListDisplayName("atlas"))).toBe("atlas") + it("resolves atlas even when a legacy ZWSP sort prefix is present on the stored key", () => { + // Users who installed v3.14.0 through v3.16.0 may have ZWSP-prefixed agent + // names baked into their config.agent keys. The resolver must still find + // the canonical config key after strip. + expect(getAgentConfigKey("\u200B\u200B\u200B\u200BAtlas - Plan Executor")).toBe("atlas") }) }) -describe("getAgentListDisplayName", () => { - it("applies invisible stable-sort prefixes to the core agent list", () => { - expect(getAgentListDisplayName("sisyphus")).toBe("\u200BSisyphus - Ultraworker") - expect(getAgentListDisplayName("hephaestus")).toBe("\u200B\u200BHephaestus - Deep Agent") - expect(getAgentListDisplayName("prometheus")).toBe("\u200B\u200B\u200BPrometheus - Plan Builder") - expect(getAgentListDisplayName("atlas")).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor") +describe("getAgentListDisplayName (deprecated alias, GH-3259)", () => { + it("returns plain display names without the legacy ZWSP sort prefix", () => { + // ZWSP prefixes were removed in #3242/#3259. This alias is retained for + // external callers that may still import it, but it now behaves + // identically to getAgentDisplayName. + expect(getAgentListDisplayName("sisyphus")).toBe("Sisyphus - Ultraworker") + expect(getAgentListDisplayName("hephaestus")).toBe("Hephaestus - Deep Agent") + expect(getAgentListDisplayName("prometheus")).toBe("Prometheus - Plan Builder") + expect(getAgentListDisplayName("atlas")).toBe("Atlas - Plan Executor") }) - it("keeps non-core agents unprefixed for list display", () => { + it("matches getAgentDisplayName for unknown agents", () => { expect(getAgentListDisplayName("oracle")).toBe("oracle") }) + + it("contains no zero-width characters in any core agent output (GH-3259)", () => { + const coreAgents = ["sisyphus", "hephaestus", "prometheus", "atlas"] + for (const agent of coreAgents) { + const result = getAgentListDisplayName(agent) + expect(result).not.toMatch(/[\u200B\u200C\u200D\uFEFF]/) + } + }) }) describe("normalizeAgentForPrompt", () => { - it("strips core UI ordering prefixes back to canonical display names", () => { - expect(normalizeAgentForPrompt(getAgentListDisplayName("sisyphus"))).toBe("Sisyphus - Ultraworker") - expect(normalizeAgentForPrompt(getAgentListDisplayName("hephaestus"))).toBe("Hephaestus - Deep Agent") - expect(normalizeAgentForPrompt(getAgentListDisplayName("prometheus"))).toBe("Prometheus - Plan Builder") - expect(normalizeAgentForPrompt(getAgentListDisplayName("atlas"))).toBe("Atlas - Plan Executor") + it("strips legacy ZWSP sort prefixes from stored agent keys back to canonical display names", () => { + // Configs from v3.14.0-v3.16.0 may persist ZWSP-prefixed keys. The + // normalizer must restore the canonical name on read. + expect(normalizeAgentForPrompt("\u200BSisyphus - Ultraworker")).toBe("Sisyphus - Ultraworker") + expect(normalizeAgentForPrompt("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent") + expect(normalizeAgentForPrompt("\u200B\u200B\u200BPrometheus - Plan Builder")).toBe("Prometheus - Plan Builder") + expect(normalizeAgentForPrompt("\u200B\u200B\u200B\u200BAtlas - Plan Executor")).toBe("Atlas - Plan Executor") }) }) diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index 426425851..2ccb545ad 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -26,13 +26,23 @@ export const AGENT_DISPLAY_NAMES: Record = { "council-member": "council-member", } -const AGENT_LIST_SORT_PREFIXES: Record = { - sisyphus: "\u200B", - hephaestus: "\u200B\u200B", - prometheus: "\u200B\u200B\u200B", - atlas: "\u200B\u200B\u200B\u200B", -} - +/** + * Strip the legacy zero-width-space sort prefix from an agent name. + * + * v3.14.0 through v3.16.0 prefixed the four core agents (Sisyphus, + * Hephaestus, Prometheus, Atlas) with U+200B Zero Width Space characters + * so they would sort ahead of user agents in the Tab cycle. Some terminal + * emulators (Ghostty, certain Windows Terminal builds) render ZWSP as a + * visible box or extra space, breaking the status bar layout (#3259), and + * the prefixes also leaked through the plugin API and broke prompt_async + * consumers (#3238). + * + * The prefixes are no longer injected anywhere (#3242 removed all call + * sites and #3259 removed the constant table). This helper remains so + * existing user configs that still have the ZWSP baked into their + * `config.agent` keys from an older install continue to resolve + * correctly after upgrading. + */ export function stripAgentListSortPrefix(agentName: string): string { return agentName.replace(/^\u200B+/, "") } @@ -58,17 +68,20 @@ export function getAgentDisplayName(configKey: string): string { } /** - * @deprecated Do NOT use for config.agent keys or API-facing names. - * ZWSP prefixes leak into the /agent API response and break prompt_async consumers. - * Use getAgentDisplayName() instead. The `order` field injected by - * reorderAgentsByPriority() handles sort ordering without invisible characters. - * See: https://github.com/code-yeongyu/oh-my-openagent/issues/3238 + * @deprecated Use {@link getAgentDisplayName} directly. + * + * Historically this returned the display name with a ZWSP sort prefix + * prepended so core agents would sort ahead of user agents in the Tab + * cycle. The ZWSP prefixes caused visible rendering artifacts in some + * terminals (#3259) and leaked into the plugin API surface (#3238), so + * they were removed in #3242/#3259. This function is now a thin alias + * over {@link getAgentDisplayName} that exists only for external + * callers that may still import it. Sort ordering is now handled by + * the `order` field injection in `reorderAgentsByPriority()` plus the + * core-first insertion order in the same helper. */ export function getAgentListDisplayName(configKey: string): string { - const displayName = getAgentDisplayName(configKey) - const prefix = AGENT_LIST_SORT_PREFIXES[configKey.toLowerCase()] - - return prefix ? `${prefix}${displayName}` : displayName + return getAgentDisplayName(configKey) } const REVERSE_DISPLAY_NAMES: Record = Object.fromEntries( From 3eb36a1807acfb05c28f8f70d3ac9e7767369857 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:34:13 +0000 Subject: [PATCH 84/86] @NikkeTryHard has signed the CLA in code-yeongyu/oh-my-openagent#3261 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 4dd237280..eb6fda9cf 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2639,6 +2639,14 @@ "created_at": "2026-04-08T15:57:15Z", "repoId": 1108837393, "pullRequestNo": 3248 + }, + { + "name": "NikkeTryHard", + "id": 111729769, + "comment_id": 4210843488, + "created_at": "2026-04-09T01:34:03Z", + "repoId": 1108837393, + "pullRequestNo": 3261 } ] } \ No newline at end of file From 00a4f318ef089d880c4e4ef7d268f44538a47315 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 9 Apr 2026 10:54:08 +0900 Subject: [PATCH 85/86] fix(migration): track applied migrations in sidecar so user reverts stick Users who auto-migrated from `openai/gpt-5.3-codex` to `openai/gpt-5.4` and then reverted their config back to `gpt-5.3-codex` by hand had the migration re-apply on every startup in an infinite loop. Discord bug report pointed at the exact symptom: "i deleted the migrations and they kept coming back". The old migration tracking lived on the config body itself as a `_migrations` string array. The skip-already-applied check relied on the user not touching that field. But users hit by the unwanted migration naturally reached for the JSON file to roll their model back, and the natural human reaction to an incomprehensible internal field next to their config is to delete it. That wiped the migration memory and let the same migration re-apply at the next startup. This PR introduces a sidecar state file that lives next to the config as `.migrations.json` and tracks applied migrations outside the user's hand-editable config body. The migration pipeline: 1. Reads applied migrations from BOTH the sidecar AND the legacy in-config `_migrations` field, unioning them. This keeps old configs that still carry `_migrations` working without forcing a reset. 2. Writes the updated migration set to the sidecar, never to the config body. 3. Strips the legacy `_migrations` field out of the config body on the first write after the sidecar takes over. Users stop seeing the mystery internal field in their own config from that point forward. If the user also deletes the sidecar (explicit fresh-start gesture) the migrations run again - that is intentional. Tests (TDD, all new tests written before implementation): - src/shared/migration/migrations-sidecar.test.ts - 11 unit tests covering read/write/round-trip, malformed-payload resilience, parent-directory creation, sorted output for stable diffs, and non-string entry filtering. - src/shared/migration.test.ts - 6 new integration tests under the "migrateConfigFile with migration tracking via sidecar" block covering: no-op path, sidecar-only write, sidecar skip after user revert, legacy _migrations mirroring + strip, sidecar + legacy union with dedupe, and partial-history append. Existing "preserves existing _migrations and appends new ones" test was rewritten to assert the new sidecar-based contract. - Also fixes a latent test-hygiene bug: the shared /tmp/nonexistent-path-for-test.json config path used by many migrateConfigFile tests did not clean up its companion sidecar between tests, letting state from one test bleed into the next. Added afterEach that unlinks the sidecar. Verified: - bun test src/shared/migration/ -> 11 new sidecar tests pass - bun test src/shared/migration.test.ts -> 82 pass, 0 fail - bun run typecheck -> clean - bun run script/run-ci-tests.ts -> 4458 pass, 0 fail (full suite) --- src/shared/migration.test.ts | 213 ++++++++++++++---- src/shared/migration/config-migration.ts | 44 +++- .../migration/migrations-sidecar.test.ts | 146 ++++++++++++ src/shared/migration/migrations-sidecar.ts | 92 ++++++++ 4 files changed, 444 insertions(+), 51 deletions(-) create mode 100644 src/shared/migration/migrations-sidecar.test.ts create mode 100644 src/shared/migration/migrations-sidecar.ts diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts index d63e9d2f1..980fa50f2 100644 --- a/src/shared/migration.test.ts +++ b/src/shared/migration.test.ts @@ -321,6 +321,18 @@ describe("migrateHookNames", () => { describe("migrateConfigFile", () => { const testConfigPath = "/tmp/nonexistent-path-for-test.json" + // Tests in this block share a single config path and do not write a real + // config file, but migrateConfigFile now persists migration tracking to a + // sidecar next to the config (#3263). Clear the sidecar between tests so + // state from an earlier test does not bleed into the next one. + afterEach(() => { + try { + fs.unlinkSync(`${testConfigPath}.migrations.json`) + } catch { + // ignore — sidecar may not exist + } + }) + test("migrates experimental.hashline_edit to top-level hashline_edit", () => { // given: Config with legacy experimental.hashline_edit const rawConfig: Record = { @@ -790,8 +802,8 @@ describe("migrateConfigFile _migrations tracking", () => { fs.rmSync(tmpDir, { recursive: true }) }) - test("preserves existing _migrations and appends new ones", () => { - // given: Config with existing migration history and a new migratable model + test("migrates legacy in-config _migrations into the sidecar and appends new migrations (#3263)", () => { + // given: Config with an existing legacy in-config _migrations history and a new migratable model const tmpDir = fs.mkdtempSync("/tmp/migration-test-") const configPath = `${tmpDir}/oh-my-opencode.json` const rawConfig: Record = { @@ -804,12 +816,17 @@ describe("migrateConfigFile _migrations tracking", () => { // when: Migrate config file const result = migrateConfigFile(configPath, rawConfig) - // then: New migration appended, old one preserved + // then: The config body has _migrations stripped. The full history + // (legacy + new) is written to the sidecar file exactly once. expect(result).toBe(true) - expect(rawConfig._migrations).toEqual([ + expect(rawConfig._migrations).toBeUndefined() + expect((rawConfig.agents as Record>).prometheus.model).toBe("anthropic/claude-opus-4-6") + + const sidecar = JSON.parse(fs.readFileSync(`${configPath}.migrations.json`, "utf-8")) + expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([ "model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex", "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", - ]) + ])) // cleanup fs.rmSync(tmpDir, { recursive: true }) @@ -1263,7 +1280,7 @@ describe("migrateModelVersions with applied migrations", () => { }) }) -describe("migrateConfigFile with _migrations tracking", () => { +describe("migrateConfigFile with migration tracking via sidecar (#3263)", () => { const cleanupPaths: string[] = [] afterEach(() => { @@ -1276,72 +1293,180 @@ describe("migrateConfigFile with _migrations tracking", () => { cleanupPaths.length = 0 }) - test("records new migrations in _migrations field", () => { - // given: Config with old model, no _migrations field - const testConfigPath = "/tmp/test-config-migrations-1.json" + function tempConfigPath(label: string): string { + const workdir = fs.mkdtempSync(`/tmp/omo-migration-${label}-`) + cleanupPaths.push(workdir) + return path.join(workdir, "oh-my-openagent.json") + } + + function sidecarPath(configPath: string): string { + return `${configPath}.migrations.json` + } + + test("does not emit migration history when no migration applies", () => { + // given: Config with a model that does not appear in MODEL_VERSION_MAP + const testConfigPath = tempConfigPath("no-op") const rawConfig: Record = { agents: { sisyphus: { model: "openai/gpt-5.4-codex" }, }, } fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - // when: Migrate config file const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - // then: gpt-5.4-codex should not create migration history expect(needsWrite).toBe(false) expect(rawConfig._migrations).toBeUndefined() expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") + expect(fs.existsSync(sidecarPath(testConfigPath))).toBe(false) }) - test("skips re-applying already-recorded migrations", () => { - // given: Config with old model but migration already in _migrations - const testConfigPath = "/tmp/test-config-migrations-2.json" + test("writes applied migrations to sidecar instead of leaving them on the config", () => { + // given: Config that needs a real model migration and has no prior history + const testConfigPath = tempConfigPath("sidecar-write") const rawConfig: Record = { agents: { - sisyphus: { model: "openai/gpt-5.4-codex" }, - }, - _migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"], - } - fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - - // when: Migrate config file - const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - - // then: Should not migrate (user reverted) - expect(needsWrite).toBe(false) - expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") - expect(rawConfig._migrations).toEqual(["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"]) - }) - - test("preserves existing _migrations and appends new ones", () => { - // given: Config with multiple old models, partial migration history - const testConfigPath = "/tmp/test-config-migrations-3.json" - const rawConfig: Record = { - agents: { - sisyphus: { model: "openai/gpt-5.4-codex" }, oracle: { model: "anthropic/claude-opus-4-5" }, }, - _migrations: ["model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex"], } fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) - cleanupPaths.push(testConfigPath) - // when: Migrate config file const needsWrite = migrateConfigFile(testConfigPath, rawConfig) - // then: Should skip sisyphus, migrate oracle, append to _migrations expect(needsWrite).toBe(true) - expect((rawConfig.agents as Record>).sisyphus.model).toBe("openai/gpt-5.4-codex") expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-6") - expect(rawConfig._migrations).toEqual([ - "model-version:openai/gpt-5.4-codex->openai/gpt-5.3-codex", + expect(rawConfig._migrations).toBeUndefined() + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", ]) }) + test("skips re-applying a migration that is recorded in the sidecar even if the user edited _migrations away", () => { + // This is the core #3263 regression: a user auto-migrated from + // gpt-5.3-codex to gpt-5.4, reverted to gpt-5.3-codex by hand, and + // deleted _migrations in the process. Without the sidecar their + // revert was clobbered on every startup. + const testConfigPath = tempConfigPath("sidecar-revert") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + }), + ) + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.3-codex" }, + }, + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + expect(needsWrite).toBe(false) + expect((rawConfig.agents as Record>).oracle.model).toBe("openai/gpt-5.3-codex") + expect(rawConfig._migrations).toBeUndefined() + }) + + test("mirrors legacy in-config _migrations into the sidecar and then strips the field", () => { + // BC path: configs written by older OMO versions still carry the + // legacy _migrations field in the JSON body. On the next startup we + // must copy that history into the new sidecar and remove the field + // from the config so the migration tracking lives in exactly one + // place from then on. + const testConfigPath = tempConfigPath("bc-mirror") + const rawConfig: Record = { + agents: { + oracle: { model: "openai/gpt-5.3-codex" }, + }, + _migrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // needsWrite is true because we rewrote the config to drop _migrations + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toBeUndefined() + expect((rawConfig.agents as Record>).oracle.model).toBe("openai/gpt-5.3-codex") + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + }) + + test("unions sidecar and legacy _migrations entries, deduplicating", () => { + // Defensive case: a config written by two different OMO versions + // could end up with an entry in _migrations that is also in the + // sidecar. The merged set should be deduplicated and the config + // should not be re-migrated. + const testConfigPath = tempConfigPath("sidecar-union") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: [ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ], + }), + ) + const rawConfig: Record = { + agents: { + oracle: { model: "anthropic/claude-opus-4-5" }, + }, + _migrations: ["model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6"], + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // needsWrite because the legacy _migrations field was stripped + expect(needsWrite).toBe(true) + expect(rawConfig._migrations).toBeUndefined() + // The reverted opus-4-5 value must be preserved + expect((rawConfig.agents as Record>).oracle.model).toBe("anthropic/claude-opus-4-5") + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(sidecar.appliedMigrations).toEqual([ + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + }) + + test("appends new migrations to the sidecar when partial history exists", () => { + // Scenario: sidecar already has one migration, a second model still + // needs to be migrated. The new migration should be recorded and the + // already-applied one preserved. + const testConfigPath = tempConfigPath("sidecar-append") + fs.writeFileSync( + sidecarPath(testConfigPath), + JSON.stringify({ + appliedMigrations: ["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"], + }), + ) + const rawConfig: Record = { + agents: { + codex: { model: "openai/gpt-5.3-codex" }, + claude: { model: "anthropic/claude-opus-4-5" }, + }, + } + fs.writeFileSync(testConfigPath, JSON.stringify(rawConfig, null, 2)) + + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + expect(needsWrite).toBe(true) + // codex was reverted, must stay + expect((rawConfig.agents as Record>).codex.model).toBe("openai/gpt-5.3-codex") + // claude migrates + expect((rawConfig.agents as Record>).claude.model).toBe("anthropic/claude-opus-4-6") + expect(rawConfig._migrations).toBeUndefined() + + const sidecar = JSON.parse(fs.readFileSync(sidecarPath(testConfigPath), "utf-8")) + expect(new Set(sidecar.appliedMigrations)).toEqual(new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ])) + }) }) diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index 58a4b4b33..894bd2dcc 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -4,6 +4,7 @@ import { writeFileAtomically } from "../write-file-atomically" import { AGENT_NAME_MAP, migrateAgentNames } from "./agent-names" import { migrateHookNames } from "./hook-names" import { migrateModelVersions } from "./model-versions" +import { readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar" export function migrateConfigFile( configPath: string, @@ -12,10 +13,22 @@ export function migrateConfigFile( const copy = structuredClone(rawConfig) let needsWrite = false - // Load previously applied migrations - const existingMigrations = Array.isArray(copy._migrations) + // Load previously applied migrations from BOTH the legacy in-config + // `_migrations` field AND the external sidecar file. The sidecar is the + // new source of truth because users were editing the config file to + // revert auto-migrated values and accidentally dropping the `_migrations` + // field in the process, which produced an infinite migration loop on + // every startup (#3263). Reading from both sources keeps old configs + // that still carry `_migrations` working without a forced reset. + const sidecarMigrations = readAppliedMigrations(configPath) + const inConfigMigrations = Array.isArray(copy._migrations) ? new Set(copy._migrations as string[]) : new Set() + const existingMigrations = new Set([ + ...sidecarMigrations, + ...inConfigMigrations, + ]) + const hadLegacyInConfigMigrations = inConfigMigrations.size > 0 const allNewMigrations: string[] = [] if (copy.agents && typeof copy.agents === "object") { @@ -54,13 +67,30 @@ export function migrateConfigFile( allNewMigrations.push(...newMigrations) } - // Record newly applied migrations - if (allNewMigrations.length > 0) { - const updatedMigrations = Array.from(existingMigrations) - updatedMigrations.push(...allNewMigrations) - copy._migrations = updatedMigrations + // Record newly applied migrations. We persist the full set (existing + + // new) to the external sidecar file and strip the legacy `_migrations` + // field from the config body on its way out, so users stop having to + // think about a field that never should have been in their config in + // the first place. The in-memory `rawConfig` never re-exposes + // `_migrations` to downstream schema validation. + const newMigrationsToRecord = allNewMigrations.filter(mKey => !existingMigrations.has(mKey)) + if (newMigrationsToRecord.length > 0 || hadLegacyInConfigMigrations) { + const fullMigrationSet = new Set([ + ...existingMigrations, + ...newMigrationsToRecord, + ]) + writeAppliedMigrations(configPath, fullMigrationSet) + } + if (newMigrationsToRecord.length > 0) { needsWrite = true } + if (hadLegacyInConfigMigrations) { + // Migrating state out of the config body is itself a config write. + needsWrite = true + } + if ("_migrations" in copy) { + delete copy._migrations + } if (copy.omo_agent) { copy.sisyphus_agent = copy.omo_agent diff --git a/src/shared/migration/migrations-sidecar.test.ts b/src/shared/migration/migrations-sidecar.test.ts new file mode 100644 index 000000000..5809bde94 --- /dev/null +++ b/src/shared/migration/migrations-sidecar.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { getSidecarPath, readAppliedMigrations, writeAppliedMigrations } from "./migrations-sidecar" + +describe("migrations sidecar", () => { + let workdir: string + + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "omo-migrations-sidecar-")) + }) + + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }) + }) + + describe("getSidecarPath", () => { + test("appends .migrations.json to the config path", () => { + expect(getSidecarPath("/home/user/.config/opencode/oh-my-openagent.json")).toBe( + "/home/user/.config/opencode/oh-my-openagent.json.migrations.json", + ) + }) + + test("works for jsonc configs too", () => { + expect(getSidecarPath("/home/user/oh-my-openagent.jsonc")).toBe( + "/home/user/oh-my-openagent.jsonc.migrations.json", + ) + }) + }) + + describe("readAppliedMigrations", () => { + test("returns an empty set when no sidecar exists", () => { + const configPath = join(workdir, "oh-my-openagent.json") + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("returns the applied migrations listed in a well-formed sidecar", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync( + getSidecarPath(configPath), + JSON.stringify({ + appliedMigrations: [ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ], + }), + ) + + const applied = readAppliedMigrations(configPath) + + expect(applied.size).toBe(2) + expect(applied.has("model-version:openai/gpt-5.3-codex->openai/gpt-5.4")).toBe(true) + expect(applied.has("model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6")).toBe(true) + }) + + test("returns an empty set on malformed JSON instead of throwing", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync(getSidecarPath(configPath), "{ this is not json") + + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("returns an empty set when the sidecar payload has the wrong shape", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync(getSidecarPath(configPath), JSON.stringify({ appliedMigrations: "not-an-array" })) + + expect(readAppliedMigrations(configPath).size).toBe(0) + }) + + test("ignores non-string entries inside appliedMigrations", () => { + const configPath = join(workdir, "oh-my-openagent.json") + writeFileSync( + getSidecarPath(configPath), + JSON.stringify({ + appliedMigrations: ["model-version:a->b", 42, null, "model-version:c->d"], + }), + ) + + const applied = readAppliedMigrations(configPath) + + expect(applied.size).toBe(2) + expect(applied.has("model-version:a->b")).toBe(true) + expect(applied.has("model-version:c->d")).toBe(true) + }) + }) + + describe("writeAppliedMigrations", () => { + test("creates the sidecar with the given migration keys", () => { + const configPath = join(workdir, "oh-my-openagent.json") + const migrations = new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + ]) + + const ok = writeAppliedMigrations(configPath, migrations) + + expect(ok).toBe(true) + expect(existsSync(getSidecarPath(configPath))).toBe(true) + + const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) + expect(body.appliedMigrations).toEqual(["model-version:openai/gpt-5.3-codex->openai/gpt-5.4"]) + }) + + test("writes entries in sorted order for stable diffs", () => { + const configPath = join(workdir, "oh-my-openagent.json") + const migrations = new Set([ + "model-version:z->y", + "model-version:a->b", + "model-version:m->n", + ]) + + writeAppliedMigrations(configPath, migrations) + + const body = JSON.parse(readFileSync(getSidecarPath(configPath), "utf-8")) + expect(body.appliedMigrations).toEqual([ + "model-version:a->b", + "model-version:m->n", + "model-version:z->y", + ]) + }) + + test("creates parent directories if they do not exist yet", () => { + const nested = join(workdir, "nested", "dir", "that", "does", "not", "exist") + const configPath = join(nested, "oh-my-openagent.json") + // Parent chain intentionally not created. + + const ok = writeAppliedMigrations(configPath, new Set(["model-version:a->b"])) + + expect(ok).toBe(true) + expect(existsSync(getSidecarPath(configPath))).toBe(true) + }) + + test("round-trips via readAppliedMigrations", () => { + const configPath = join(workdir, "oh-my-openagent.jsonc") + const original = new Set([ + "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6", + ]) + + writeAppliedMigrations(configPath, original) + const roundTripped = readAppliedMigrations(configPath) + + expect(roundTripped).toEqual(original) + }) + }) +}) diff --git a/src/shared/migration/migrations-sidecar.ts b/src/shared/migration/migrations-sidecar.ts new file mode 100644 index 000000000..cd0088922 --- /dev/null +++ b/src/shared/migration/migrations-sidecar.ts @@ -0,0 +1,92 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { log } from "../logger" +import { writeFileAtomically } from "../write-file-atomically" + +/** + * Sidecar state file that tracks applied config migrations outside the user's + * config file. + * + * Why this exists (#3263): users who revert an auto-migrated value (e.g. + * `gpt-5.4` → `gpt-5.3-codex`) and then delete the `_migrations` field from + * their config would fall into an infinite migration loop — every startup + * re-applied the migration because there was no memory of the previous + * application. The sidecar remembers applied migrations even when the user + * scrubs the config, and only "resets" when the user explicitly deletes both + * the config and the sidecar. + * + * The sidecar lives next to the config file as + * `.migrations.json`. One sidecar per config file. The file + * format is a flat JSON object: + * + * { + * "appliedMigrations": [ + * "model-version:openai/gpt-5.3-codex->openai/gpt-5.4", + * "model-version:anthropic/claude-opus-4-5->anthropic/claude-opus-4-6" + * ] + * } + */ + +export interface MigrationsSidecar { + appliedMigrations: string[] +} + +export function getSidecarPath(configPath: string): string { + return `${configPath}.migrations.json` +} + +/** + * Read the set of applied migration keys from the sidecar next to + * `configPath`. Returns an empty set on any read or parse failure so the + * caller can still trust the return value and safely fall back to the + * config's `_migrations` field. + */ +export function readAppliedMigrations(configPath: string): Set { + const sidecarPath = getSidecarPath(configPath) + try { + if (!fs.existsSync(sidecarPath)) { + return new Set() + } + const content = fs.readFileSync(sidecarPath, "utf-8") + const parsed = JSON.parse(content) as unknown + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + Array.isArray((parsed as MigrationsSidecar).appliedMigrations) + ) { + return new Set((parsed as MigrationsSidecar).appliedMigrations.filter((m): m is string => typeof m === "string")) + } + return new Set() + } catch (err) { + log(`[migration] Failed to read migrations sidecar at ${sidecarPath}`, err) + return new Set() + } +} + +/** + * Persist the given set of applied migration keys to the sidecar next to + * `configPath`. The sidecar is written atomically. Returns true on success, + * false if the write failed (the caller can still proceed — the next + * startup will re-run the migration, which is idempotent by design). + */ +export function writeAppliedMigrations(configPath: string, migrations: Set): boolean { + const sidecarPath = getSidecarPath(configPath) + const body: MigrationsSidecar = { + appliedMigrations: Array.from(migrations).sort(), + } + try { + // Ensure the parent directory exists in case the config file was created + // out-of-band. We intentionally do NOT create the sidecar when the migration + // set is empty — there is nothing to remember. + const parentDir = path.dirname(sidecarPath) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + writeFileAtomically(sidecarPath, JSON.stringify(body, null, 2) + "\n") + return true + } catch (err) { + log(`[migration] Failed to write migrations sidecar at ${sidecarPath}`, err) + return false + } +} From ef95a99420f0bdecd926a5b9fe2b66d2b54a60f7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:46:38 +0000 Subject: [PATCH 86/86] @gwegwe1234 has signed the CLA in code-yeongyu/oh-my-openagent#3264 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index eb6fda9cf..704b21266 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -2647,6 +2647,14 @@ "created_at": "2026-04-09T01:34:03Z", "repoId": 1108837393, "pullRequestNo": 3261 + }, + { + "name": "gwegwe1234", + "id": 43298107, + "comment_id": 4211103484, + "created_at": "2026-04-09T02:46:26Z", + "repoId": 1108837393, + "pullRequestNo": 3264 } ] } \ No newline at end of file