merge dev into continuation runtime retry

# Conflicts:
#	src/hooks/ralph-loop/non-abort-error-continuation.test.ts
This commit is contained in:
YeonGyu-Kim
2026-05-07 11:34:33 +09:00
104 changed files with 2077 additions and 1654 deletions
+3 -3
View File
@@ -15,11 +15,11 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
| Agent | Model | Temp | Mode | Fallback Chain | Purpose |
|-------|-------|------|------|----------------|---------|
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 -> kimi-k2.5 -> gpt-5.5 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 -> kimi-k2.6 -> gpt-5.5 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
| **Hephaestus** | gpt-5.5 medium | 0.1 | all | — | Autonomous deep worker |
| **Oracle** | gpt-5.5 high | 0.1 | subagent | gemini-3.1-pro high -> claude-opus-4-7 max | Read-only consultation |
| **Librarian** | gpt-5.4-mini-fast | 0.1 | subagent | minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | External docs/code search |
| **Explore** | gpt-5.4-mini-fast | 0.1 | subagent | minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | Contextual grep |
| **Librarian** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus -> minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | External docs/code search |
| **Explore** | gpt-5.4-mini-fast | 0.1 | subagent | qwen3.5-plus -> minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5.4-nano | Contextual grep |
| **Multimodal-Looker** | gpt-5.3-codex medium | 0.1 | subagent | k2p5 -> gemini-3-flash -> glm-4.6v -> gpt-5-nano | PDF/image analysis |
| **Metis** | claude-opus-4-7 max | **0.3** | subagent | gpt-5.5 high -> gemini-3.1-pro high | Pre-planning consultant |
| **Momus** | gpt-5.5 xhigh | 0.1 | subagent | claude-opus-4-7 max -> gemini-3.1-pro high | Plan reviewer |
+1 -1
View File
@@ -196,7 +196,7 @@ export function buildNonClaudePlannerSection(model: string): string {
Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan.
- Single-file fix or trivial change → proceed directly
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="prometheus", ...)\` FIRST
- Use \`task_id\` to resume the same Plan Agent - ask follow-up questions aggressively
- If ANY part of the task is ambiguous, ask Plan Agent before guessing
+1
View File
@@ -17,6 +17,7 @@ description: Developer reference for the Hephaestus autonomous deep worker agent
|------|---------|
| `agent.ts` | `createHephaestusAgent()` factory, model-variant routing |
| `gpt.ts` | Base GPT prompt: discipline rules, delegation, verification |
| `gpt-5-5.ts` | GPT-5.5-native prompt tuned for current Hephaestus routing |
| `gpt-5-4.ts` | GPT-5.4-native prompt with XML-tagged blocks, entropy-reduced |
| `gpt-5-3-codex.ts` | GPT-5.3 Codex variant with task discipline sections |
| `index.ts` | Barrel exports |
+3 -1
View File
@@ -9,7 +9,7 @@ description: Developer reference for Sisyphus orchestrator model-specific prompt
## OVERVIEW
4 files. Model-specific prompt variants for the Sisyphus main orchestrator. Parent `sisyphus.ts` routes to the correct variant based on active model.
5 prompt/export files. Model-specific prompt variants for the Sisyphus main orchestrator. Parent `sisyphus.ts` routes to the correct variant based on active model.
## FILES
@@ -18,12 +18,14 @@ description: Developer reference for Sisyphus orchestrator model-specific prompt
| `default.ts` | Base/Claude variant: task management, delegation guides, 542 LOC |
| `gemini.ts` | Gemini-optimized: stricter tool-usage rules, 5 NEVER rules |
| `gpt-5-4.ts` | GPT-5.4-native: 8-block architecture, entropy-reduced, 449 LOC |
| `gpt-5-5.ts` | GPT-5.5-native: updated orchestration prompt tuned for GPT-5.5 |
| `index.ts` | Barrel exports |
## VARIANT SELECTION
Parent `sisyphus.ts` selects variant by model name:
- Contains "gemini" -> `gemini.ts`
- Contains "gpt-5.5" -> `gpt-5-5.ts`
- Contains "gpt-5.4" -> `gpt-5-4.ts`
- Default -> `default.ts` (Claude, Kimi, GLM, etc.)
+1 -1
View File
@@ -287,7 +287,7 @@ Every implementation task follows this cycle. No exceptions.
Follow \`<explore>\` protocol for tool usage and agent prompts.
2. PLAN - List files to modify, specific changes, dependencies, complexity estimate.
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="plan", ...)\`.
Multi-step (2+) → consult Plan Agent via \`task(subagent_type="prometheus", ...)\`.
Single-step → mental plan is sufficient.
<dependency_checks>
+23 -23
View File
@@ -60,14 +60,14 @@ describe("createBuiltinAgents with model overrides", () => {
const providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
const overrides = {
sisyphus: { model: "github-copilot/gpt-5.4" },
sisyphus: { model: "github-copilot/gpt-5.5" },
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.4")
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.5")
expect(agents.sisyphus.reasoningEffort).toBe("medium")
expect(agents.sisyphus.thinking).toBeUndefined()
providerModelsSpy.mockRestore()
@@ -77,9 +77,9 @@ describe("createBuiltinAgents with model overrides", () => {
test("Atlas uses uiSelectedModel", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"])
new Set(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"])
)
const uiSelectedModel = "openai/gpt-5.4"
const uiSelectedModel = "openai/gpt-5.5"
try {
// #when
@@ -98,7 +98,7 @@ describe("createBuiltinAgents with model overrides", () => {
// #then
expect(agents.atlas).toBeDefined()
expect(agents.atlas.model).toBe("openai/gpt-5.4")
expect(agents.atlas.model).toBe("openai/gpt-5.5")
} finally {
fetchSpy.mockRestore()
}
@@ -107,9 +107,9 @@ describe("createBuiltinAgents with model overrides", () => {
test("user config model takes priority over uiSelectedModel for sisyphus", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"])
new Set(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"])
)
const uiSelectedModel = "openai/gpt-5.4"
const uiSelectedModel = "openai/gpt-5.5"
const overrides = {
sisyphus: { model: "google/antigravity-claude-opus-4-5-thinking" },
}
@@ -140,9 +140,9 @@ describe("createBuiltinAgents with model overrides", () => {
test("user config model takes priority over uiSelectedModel for atlas", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.4", "anthropic/claude-sonnet-4-6"])
new Set(["openai/gpt-5.5", "anthropic/claude-sonnet-4-6"])
)
const uiSelectedModel = "openai/gpt-5.4"
const uiSelectedModel = "openai/gpt-5.5"
const overrides = {
atlas: { model: "google/antigravity-claude-opus-4-5-thinking" },
}
@@ -265,14 +265,14 @@ describe("createBuiltinAgents with model overrides", () => {
const providerModelsSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
const overrides = {
sisyphus: { model: "github-copilot/gpt-5.4", temperature: 0.5 },
sisyphus: { model: "github-copilot/gpt-5.5", temperature: 0.5 },
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.4")
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.5")
expect(agents.sisyphus.temperature).toBe(0.5)
providerModelsSpy.mockRestore()
fetchSpy.mockRestore()
@@ -306,7 +306,7 @@ describe("createBuiltinAgents with model overrides", () => {
"opencode/kimi-k2.5-free",
"zai-coding-plan/glm-5",
"opencode/big-pickle",
"openai/gpt-5.4",
"openai/gpt-5.5",
])
)
@@ -343,7 +343,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("excludes hidden custom agents from orchestrator prompts", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
)
const customAgentSummaries = [
@@ -379,7 +379,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("excludes disabled custom agents from orchestrator prompts", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
)
const customAgentSummaries = [
@@ -415,7 +415,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("excludes custom agents when disabledAgents contains their name (case-insensitive)", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
)
const disabledAgents = ["ReSeArChEr"]
@@ -451,7 +451,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("does not advertise duplicate custom agents case-insensitively", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
)
const customAgentSummaries = [
@@ -483,7 +483,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("does not surface custom agent strings in orchestrator prompts", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
)
const customAgentSummaries = [
@@ -842,7 +842,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
beforeEach(() => {
fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.5"])
)
})
@@ -968,7 +968,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
// #given - user configures a model from a plugin provider (like antigravity)
// that is NOT in the availableModels cache and NOT in the fallback chain
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.4"])
new Set(["openai/gpt-5.5"])
)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(
["openai"]
@@ -1098,7 +1098,7 @@ describe("buildAgent with category and skills", () => {
const categories = {
"custom-category": {
model: "openai/gpt-5.4",
model: "openai/gpt-5.5",
variant: "xhigh",
},
}
@@ -1107,7 +1107,7 @@ describe("buildAgent with category and skills", () => {
const agent = buildAgent(source["test-agent"], TEST_MODEL, categories)
// #then
expect(agent.model).toBe("openai/gpt-5.4")
expect(agent.model).toBe("openai/gpt-5.5")
expect(agent.variant).toBe("xhigh")
})
@@ -1357,7 +1357,7 @@ describe("override.category expansion in createBuiltinAgents", () => {
// #given - custom category has reasoningEffort=xhigh, direct override says "low"
const categories = {
"test-cat": {
model: "openai/gpt-5.4",
model: "openai/gpt-5.5",
reasoningEffort: "xhigh" as const,
},
}
@@ -1377,7 +1377,7 @@ describe("override.category expansion in createBuiltinAgents", () => {
// #given - custom category has reasoningEffort, no direct reasoningEffort in override
const categories = {
"reasoning-cat": {
model: "openai/gpt-5.4",
model: "openai/gpt-5.5",
reasoningEffort: "high" as const,
},
}
@@ -4138,7 +4138,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"atlas": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/openai/gpt-5.5",
@@ -4189,7 +4189,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "high",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/anthropic/claude-opus-4.7",
@@ -4206,7 +4206,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "high",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/openai/gpt-5.5",
@@ -4215,7 +4215,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"multimodal-looker": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/zai/glm-4.6v",
@@ -4238,7 +4238,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "max",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/openai/gpt-5.5",
@@ -4251,7 +4251,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "high",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
{
"model": "vercel/google/gemini-3.1-pro-preview",
@@ -4262,6 +4262,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
},
"sisyphus": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/moonshotai/kimi-k2.5",
},
@@ -4279,7 +4282,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"sisyphus-junior": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/openai/gpt-5.5",
@@ -4348,7 +4351,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "max",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/openai/gpt-5.5",
@@ -4361,7 +4364,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "medium",
},
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/google/gemini-3-flash",
@@ -4379,7 +4382,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "medium",
},
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/google/gemini-3-flash",
@@ -4399,6 +4402,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"model": "vercel/anthropic/claude-opus-4.7",
"variant": "max",
},
{
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/google/gemini-3.1-pro-preview",
"variant": "high",
@@ -4406,7 +4412,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"writing": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/anthropic/claude-sonnet-4.6",
@@ -4428,7 +4434,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"atlas": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/openai/gpt-5.5",
@@ -4479,7 +4485,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "high",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/anthropic/claude-opus-4.7",
@@ -4496,7 +4502,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "high",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/openai/gpt-5.5",
@@ -4505,7 +4511,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"multimodal-looker": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/zai/glm-4.6v",
@@ -4528,7 +4534,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "max",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/openai/gpt-5.5",
@@ -4541,7 +4547,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "high",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
{
"model": "vercel/google/gemini-3.1-pro-preview",
@@ -4552,6 +4558,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
},
"sisyphus": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/moonshotai/kimi-k2.5",
},
@@ -4569,7 +4578,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"sisyphus-junior": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/openai/gpt-5.5",
@@ -4638,7 +4647,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "max",
},
{
"model": "vercel/zai/glm-5",
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/openai/gpt-5.5",
@@ -4653,6 +4662,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
{
"model": "vercel/zai/glm-5",
},
{
"model": "vercel/zai/glm-5.1",
},
{
"model": "vercel/moonshotai/kimi-k2.5",
},
@@ -4667,7 +4679,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"variant": "medium",
},
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/google/gemini-3-flash",
@@ -4687,6 +4699,9 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"model": "vercel/anthropic/claude-opus-4.7",
"variant": "max",
},
{
"model": "vercel/zai/glm-5.1",
},
],
"model": "vercel/google/gemini-3.1-pro-preview",
"variant": "high",
@@ -4694,7 +4709,7 @@ exports[`generateModelConfig Vercel AI Gateway provider uses vercel/ model strin
"writing": {
"fallback_models": [
{
"model": "vercel/moonshotai/kimi-k2.5",
"model": "vercel/moonshotai/kimi-k2.6",
},
{
"model": "vercel/anthropic/claude-sonnet-4.6",
@@ -54,7 +54,7 @@ describe("detectCurrentConfig - single package detection", () => {
it("detects OpenCode Go from the existing omo config", () => {
// given
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
writeFileSync(testOmoConfigPath, JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.5" } } }, null, 2) + "\n", "utf-8")
writeFileSync(testOmoConfigPath, JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.6" } } }, null, 2) + "\n", "utf-8")
// when
const result = detectCurrentConfig()
@@ -31,13 +31,13 @@ describe("model-resolution-config", () => {
process.env.OPENCODE_CONFIG_DIR = testConfigDir
writeFileSync(
join(testConfigDir, "oh-my-openagent.json"),
JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.5" } } }, null, 2) + "\n",
JSON.stringify({ agents: { atlas: { model: "opencode-go/kimi-k2.6" } } }, null, 2) + "\n",
"utf-8",
)
const config = loadOmoConfig()
expect(config?.agents?.atlas?.model).toBe("opencode-go/kimi-k2.5")
expect(config?.agents?.atlas?.model).toBe("opencode-go/kimi-k2.6")
} finally {
rmSync(testConfigDir, { recursive: true, force: true })
}
+35
View File
@@ -0,0 +1,35 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it, mock } from "bun:test"
const originalWhich = Bun.which
afterEach(() => {
Bun.which = originalWhich
mock.restore()
})
describe("getGhCliInfo", () => {
it("falls back to gh --version when Bun.which cannot find gh", async () => {
// given
Bun.which = mock(() => null)
mock.module("../spawn-with-timeout", () => ({
spawnWithTimeout: mock((command: string[]) => {
if (command.join(" ") === "gh --version") {
return Promise.resolve({ stdout: "gh version 2.82.1\n", stderr: "", exitCode: 0, timedOut: false })
}
return Promise.resolve({ stdout: "", stderr: "not logged in", exitCode: 1, timedOut: false })
}),
}))
const { getGhCliInfo } = await import("./tools-gh")
// when
const info = await getGhCliInfo()
// then
expect(info.installed).toBe(true)
expect(info.version).toBe("2.82.1")
expect(info.path).toBe(null)
})
})
+14
View File
@@ -80,6 +80,20 @@ async function getGhAuthStatus(): Promise<{
export async function getGhCliInfo(): Promise<GhCliInfo> {
const binaryStatus = await checkBinaryExists("gh")
if (!binaryStatus.exists) {
const version = await getGhVersion()
if (version) {
const authStatus = await getGhAuthStatus()
return {
installed: true,
version,
path: null,
authenticated: authStatus.authenticated,
username: authStatus.username,
scopes: authStatus.scopes,
error: authStatus.error,
}
}
return {
installed: false,
version: null,
+11 -2
View File
@@ -1,9 +1,18 @@
import type { DoctorOptions } from "./types"
import { runDoctor } from "./runner"
import { EXIT_CODES } from "./constants"
export async function doctor(options: DoctorOptions = { mode: "default" }): Promise<number> {
const result = await runDoctor(options)
return result.exitCode
try {
const result = await runDoctor(options)
return result.exitCode
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error("\nDoctor failed unexpectedly:", message)
console.error("This may indicate memory pressure (OOM/SIGKILL) or a corrupted installation.")
console.error("Try: OMO_DISABLE_POSTHOG=1 bunx oh-my-opencode doctor --verbose\n")
return EXIT_CODES.FAILURE
}
}
export * from "./types"
+2 -2
View File
@@ -130,7 +130,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
if (avail.native.openai) {
agentConfig = { model: "openai/gpt-5.4-mini-fast" }
} else if (avail.opencodeGo) {
agentConfig = { model: "opencode-go/minimax-m2.7" }
agentConfig = { model: "opencode-go/qwen3.5-plus" }
} else if (avail.zai) {
agentConfig = { model: ZAI_MODEL }
} else if (avail.vercelAiGateway) {
@@ -151,7 +151,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
} else if (avail.opencodeZen) {
agentConfig = { model: "opencode/claude-haiku-4-5" }
} else if (avail.opencodeGo) {
agentConfig = { model: "opencode-go/minimax-m2.7" }
agentConfig = { model: "opencode-go/qwen3.5-plus" }
} else if (avail.copilot) {
agentConfig = { model: "github-copilot/gpt-5-mini" }
} else if (avail.vercelAiGateway) {
@@ -105,6 +105,41 @@ describe("checkCompletionConditions continuation coverage", () => {
expect(result).toBe(true)
})
it("returns true when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
const directory = createTempDir()
const mainPlanPath = join(directory, ".sisyphus", "plans", "done-in-worktree-plan.md")
const worktreeDirectory = createTempDir()
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "done-in-worktree-plan.md")
mkdirSync(join(directory, ".sisyphus", "plans"), { recursive: true })
mkdirSync(join(worktreeDirectory, ".sisyphus", "plans"), { recursive: true })
writeFileSync(mainPlanPath, "- [ ] stale main repo task\n", "utf-8")
writeFileSync(worktreePlanPath, "- [x] completed worktree task\n", "utf-8")
const sisyphusDir = join(directory, ".sisyphus")
mkdirSync(sisyphusDir, { recursive: true })
writeFileSync(
join(sisyphusDir, "boulder.json"),
JSON.stringify({
active_plan: mainPlanPath,
started_at: new Date().toISOString(),
session_ids: ["test-session"],
plan_name: "done-in-worktree-plan",
agent: "atlas",
worktree_path: worktreeDirectory,
}),
"utf-8",
)
const ctx = createMockContext(directory)
const { checkCompletionConditions } = await import("./completion")
// when
const result = await checkCompletionConditions(ctx)
// then
expect(result).toBe(true)
})
it("returns false when current session is an appended descendant of an active boulder session with unchecked plan items", async () => {
// given
spyOn(console, "log").mockImplementation(() => {})
+2 -2
View File
@@ -1,4 +1,4 @@
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
import { getSessionAgent } from "../../features/claude-code-session-state"
import {
getActiveContinuationMarkerReason,
@@ -47,7 +47,7 @@ async function hasActiveBoulderContinuation(
const boulder = readBoulderState(directory)
if (!boulder) return false
const progress = getPlanProgress(boulder.active_plan)
const progress = getPlanProgress(resolveBoulderPlanPath(directory, boulder))
if (progress.isComplete) return false
if (!client) return false
+1 -1
View File
@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
})
.optional(),
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(),
/** Text verbosity level. */
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
/** Provider-specific options. Passed directly to OpenCode SDK. */
+1 -1
View File
@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
budgetTokens: z.number().optional(),
})
.optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(),
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(),
+1 -1
View File
@@ -3,7 +3,7 @@ import { z } from "zod"
export const FallbackModelObjectSchema = z.object({
model: z.string(),
variant: z.string().optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh", "max"]).optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
maxTokens: z.number().optional(),
@@ -23,6 +23,12 @@ mock.module("../../shared/connected-providers-cache", () => ({
writeProviderModelsCache: () => {},
updateConnectedProvidersCache: () => {},
}))
mock.module("../../shared/frontmatter", () => ({
parseFrontmatter: () => ({ frontmatter: {}, content: "" }),
}))
mock.module("js-yaml", () => ({
load: () => ({}),
}))
mock.restore()
@@ -2447,6 +2453,69 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(task.sessionId).toBeUndefined()
})
test("should sanitize wrapped agent names before task creation and queueing", async () => {
// given
const input = {
description: "Test task",
prompt: "Do something",
agent: "\\hephaestus\\",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
}
// when
const task = await manager.launch(input)
const queueItem = getQueuesByKey(manager).values().next().value?.[0]
// then
expect(task.agent).toBe("hephaestus")
expect(getTaskMap(manager).get(task.id)?.agent).toBe("hephaestus")
// queueItem may be undefined if the queue was immediately processed
if (queueItem) {
expect(queueItem.input.agent).toBe("hephaestus")
}
})
test("should sanitize slash and quote wrapped agent names before task creation and queueing", async () => {
// given
const input = {
description: "Test task",
prompt: "Do something",
agent: "\"/hephaestus/\"",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
}
// when
const task = await manager.launch(input)
const queueItem = getQueuesByKey(manager).values().next().value?.[0]
// then
expect(task.agent).toBe("hephaestus")
expect(getTaskMap(manager).get(task.id)?.agent).toBe("hephaestus")
// queueItem may be undefined if the queue was immediately processed
if (queueItem) {
expect(queueItem.input.agent).toBe("hephaestus")
}
})
test("should reject wrapper-only agent names after sanitization", async () => {
// given
const input = {
description: "Test task",
prompt: "Do something",
agent: "\\\"/'\\\"/",
parentSessionId: "parent-session",
parentMessageId: "parent-message",
}
// when
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("Agent parameter is required after sanitization")
})
test("should initialize attempt state for a newly launched task", async () => {
// given
const input = {
+6
View File
@@ -383,6 +383,12 @@ export class BackgroundManager {
throw new Error("Agent parameter is required")
}
input = { ...input, agent: input.agent.trim().replace(/^[\\/"']+|[\\/"']+$/g, "").trim() }
if (!input.agent) {
throw new Error("Agent parameter is required after sanitization")
}
const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionId)
try {
+44 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { tmpdir } from "node:os"
import {
readBoulderState,
@@ -12,6 +12,7 @@ import {
createBoulderState,
findPrometheusPlans,
getTaskSessionState,
resolveBoulderPlanPath,
upsertTaskSessionState,
} from "./storage"
import type { BoulderState } from "./types"
@@ -778,4 +779,46 @@ describe("boulder-state", () => {
expect(state.agent).toBeUndefined()
})
})
describe("resolveBoulderPlanPath", () => {
test("should prefer the mirrored worktree plan when it exists", () => {
// given
const planPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-plan.md")
const worktreeDir = join(tmpdir(), `boulder-state-worktree-${Date.now()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-plan.md")
mkdirSync(dirname(planPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
try {
// when
const resolvedPath = resolveBoulderPlanPath(TEST_DIR, {
active_plan: planPath,
worktree_path: worktreeDir,
})
// then
expect(resolvedPath).toBe(worktreePlanPath)
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
test("should fall back to the tracked plan when the mirrored worktree plan is missing", () => {
// given
const planPath = join(TEST_DIR, ".sisyphus", "plans", "fallback-plan.md")
mkdirSync(dirname(planPath), { recursive: true })
writeFileSync(planPath, "# Plan\n- [ ] Main repo task\n")
// when
const resolvedPath = resolveBoulderPlanPath(TEST_DIR, {
active_plan: planPath,
worktree_path: join(tmpdir(), `missing-worktree-${Date.now()}`),
})
// then
expect(resolvedPath).toBe(planPath)
})
})
})
+34 -1
View File
@@ -5,7 +5,7 @@
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
import { dirname, join, basename } from "node:path"
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
@@ -15,6 +15,39 @@ export function getBoulderFilePath(directory: string): string {
return join(directory, BOULDER_DIR, BOULDER_FILE)
}
function resolveTrackedPath(baseDirectory: string, trackedPath: string): string {
return isAbsolute(trackedPath)
? resolve(trackedPath)
: resolve(baseDirectory, trackedPath)
}
export function resolveBoulderPlanPath(
directory: string,
state: Pick<BoulderState, "active_plan" | "worktree_path">,
): string {
const absolutePlanPath = resolveTrackedPath(directory, state.active_plan)
const worktreePath = state.worktree_path?.trim()
if (!worktreePath) {
return absolutePlanPath
}
const absoluteDirectory = resolve(directory)
const relativePlanPath = relative(absoluteDirectory, absolutePlanPath)
if (
relativePlanPath.length === 0
|| relativePlanPath.startsWith("..")
|| isAbsolute(relativePlanPath)
) {
return absolutePlanPath
}
const absoluteWorktreePath = resolveTrackedPath(directory, worktreePath)
const worktreePlanPath = resolve(absoluteWorktreePath, relativePlanPath)
return existsSync(worktreePlanPath)
? worktreePlanPath
: absolutePlanPath
}
export function readBoulderState(directory: string): BoulderState | null {
const filePath = getBoulderFilePath(directory)
+37 -35
View File
@@ -18,9 +18,9 @@ Analyze the user's request to determine operation mode:
| User Request Pattern | Mode | Jump To |
|---------------------|------|---------|
| "commit", "커밋", changes to commit | `COMMIT` | Phase 0-6 (existing) |
| "rebase", "리베이스", "squash", "cleanup history" | `REBASE` | Phase R1-R4 |
| "find when", "who changed", "언제 바뀌었", "git blame", "bisect" | `HISTORY_SEARCH` | Phase H1-H3 |
| Commit intent in any language (e.g., "commit", "커밋", "コミット") | `COMMIT` | Phase 0-6 (existing) |
| Rebase/squash intent in any language (e.g., "rebase", "리베이스", "リベース") | `REBASE` | Phase R1-R4 |
| History lookup intent in any language (e.g., "find when", "언제 바뀌었", "いつ追加") | `HISTORY_SEARCH` | Phase H1-H3 |
| "smart rebase", "rebase onto" | `REBASE` | Phase R1-R4 |
**CRITICAL**: Don't default to COMMIT mode. Parse the actual request.
@@ -107,18 +107,18 @@ git log --oneline $(git merge-base HEAD main 2>/dev/null || git merge-base HEAD
<style_detection>
**THIS PHASE HAS MANDATORY OUTPUT** - You MUST print the analysis result before moving to Phase 2.
### 1.1 Language Detection
### 1.1 Language Profile Detection
```
Count from git log -30:
- Korean characters: N commits
- English only: M commits
- Mixed: K commits
- Dominant language/script patterns: N commits
- Secondary language/script patterns: M commits
- Mixed/ambiguous: K commits
DECISION:
- If Korean >= 50% -> KOREAN
- If English >= 50% -> ENGLISH
- If Mixed -> Use MAJORITY language
- Preserve the dominant repository language pattern in commit messages
- If multiple languages are common, follow the nearest recent examples for the same module
- Never restrict output to specific languages; support any language used by the repo (e.g., Japanese, Korean, English, etc.)
```
### 1.2 Commit Style Classification
@@ -151,9 +151,9 @@ STYLE DETECTION RESULT
======================
Analyzed: 30 commits from git log
Language: [KOREAN | ENGLISH]
- Korean commits: N (X%)
- English commits: M (Y%)
Language profile: [DOMINANT_LANGUAGE_OR_SCRIPT]
- Dominant pattern: N (X%)
- Secondary pattern: M (Y%)
Style: [SEMANTIC | PLAIN | SENTENCE | SHORT]
- Semantic (feat:, fix:, etc): N (X%)
@@ -165,7 +165,7 @@ Reference examples from repo:
2. "actual commit message from log"
3. "actual commit message from log"
All commits will follow: [LANGUAGE] + [STYLE]
All commits will follow: [DOMINANT_LANGUAGE_OR_SCRIPT] + [STYLE]
```
**IF YOU SKIP THIS OUTPUT, YOUR COMMITS WILL BE WRONG. STOP AND REDO.**
@@ -507,17 +507,19 @@ git log -1 --oneline
**Based on COMMIT_CONFIG from Phase 1:**
```
IF style == SEMANTIC AND language == KOREAN:
-> "feat: 로그인 기능 추가"
IF style == SEMANTIC AND language == ENGLISH:
-> "feat: add login feature"
IF style == PLAIN AND language == KOREAN:
-> "로그인 기능 추가"
IF style == PLAIN AND language == ENGLISH:
-> "Add login feature"
IF style == SEMANTIC:
-> Use a semantic prefix + repository language message
-> Examples:
- "feat: add login feature"
- "feat: ログイン機能を追加"
- "feat: 로그인 기능 추가"
IF style == PLAIN:
-> Use plain repository language message without semantic prefix
-> Examples:
- "Add login feature"
- "ログイン機能を追加"
- "로그인 기능 추가"
IF style == SHORT:
-> "format" / "type fix" / "lint"
@@ -525,7 +527,7 @@ IF style == SHORT:
**VALIDATION before each commit:**
1. Does message match detected style?
2. Does language match detected language?
2. Does message use the repository's dominant language/script profile (from Phase 1.1)?
3. Is it similar to examples from git log?
If ANY check fails -> REWRITE message.
@@ -589,7 +591,7 @@ NEXT STEPS:
| If git log shows... | Use this style |
|---------------------|----------------|
| `feat: xxx`, `fix: yyy` | SEMANTIC |
| `Add xxx`, `Fix yyy`, `xxx 추가` | PLAIN |
| `Add xxx`, `Fix yyy`, `xxx 추가`, `xxxを追加` | PLAIN |
| `format`, `lint`, `typo` | SHORT |
| Full sentences | SENTENCE |
| Mix of above | Use MAJORITY (not semantic by default) |
@@ -691,16 +693,16 @@ USER REQUEST -> STRATEGY:
"squash commits" / "cleanup" / "정리"
-> INTERACTIVE_SQUASH
"rebase on main" / "update branch" / "메인에 리베이스"
"rebase on main" intent in any language (e.g., "update branch", "메인에 리베이스", "mainにリベース")
-> REBASE_ONTO_BASE
"autosquash" / "apply fixups"
-> AUTOSQUASH
"reorder commits" / "커밋 순서"
"reorder commits" intent in any language (e.g., "커밋 순서", "コミット順を並べ替え")
-> INTERACTIVE_REORDER
"split commit" / "커밋 분리"
"split commit" intent in any language (e.g., "커밋 분리", "コミット分割")
-> INTERACTIVE_EDIT
```
</rebase_context>
@@ -850,12 +852,12 @@ NEXT STEPS:
| User Request | Search Type | Tool |
|--------------|-------------|------|
| "when was X added" / "X가 언제 추가됐어" | PICKAXE | `git log -S` |
| "when was X added" in any language (e.g., "X가 언제 추가됐어", "Xはいつ追加された") | PICKAXE | `git log -S` |
| "find commits changing X pattern" | REGEX | `git log -G` |
| "who wrote this line" / "이 줄 누가 썼어" | BLAME | `git blame` |
| "when did bug start" / "버그 언제 생겼어" | BISECT | `git bisect` |
| "history of file" / "파일 히스토리" | FILE_LOG | `git log -- path` |
| "find deleted code" / "삭제된 코드 찾기" | PICKAXE_ALL | `git log -S --all` |
| "who wrote this line" in any language (e.g., "이 줄 누가 썼어", "この行を書いたのは誰") | BLAME | `git blame` |
| "when did bug start" in any language (e.g., "버그 언제 생겼어", "バグはいつ入った") | BISECT | `git bisect` |
| "history of file" in any language (e.g., "파일 히스토리", "ファイル履歴") | FILE_LOG | `git log -- path` |
| "find deleted code" in any language (e.g., "삭제된 코드 찾기", "削除されたコードを探す") | PICKAXE_ALL | `git log -S --all` |
### H1.2 Extract Search Parameters
@@ -35,18 +35,18 @@ git log --oneline $(git merge-base HEAD main 2>/dev/null || git merge-base HEAD
<style_detection>
**THIS PHASE HAS MANDATORY OUTPUT** - You MUST print the analysis result before moving to Phase 2.
### 1.1 Language Detection
### 1.1 Language Profile Detection
\`\`\`
Count from git log -30:
- Korean characters: N commits
- English only: M commits
- Mixed: K commits
- Dominant language/script patterns: N commits
- Secondary language/script patterns: M commits
- Mixed/ambiguous: K commits
DECISION:
- If Korean >= 50% -> KOREAN
- If English >= 50% -> ENGLISH
- If Mixed -> Use MAJORITY language
- Preserve the dominant repository language pattern in commit messages
- If multiple languages are common, follow the nearest recent examples for the same module
- Never restrict output to specific languages; support any language used by the repo (e.g., Japanese, Korean, English, etc.)
\`\`\`
### 1.2 Commit Style Classification
@@ -79,9 +79,9 @@ STYLE DETECTION RESULT
======================
Analyzed: 30 commits from git log
Language: [KOREAN | ENGLISH]
- Korean commits: N (X%)
- English commits: M (Y%)
Language profile: [DOMINANT_LANGUAGE_OR_SCRIPT]
- Dominant pattern: N (X%)
- Secondary pattern: M (Y%)
Style: [SEMANTIC | PLAIN | SENTENCE | SHORT]
- Semantic (feat:, fix:, etc): N (X%)
@@ -93,7 +93,7 @@ Reference examples from repo:
2. "actual commit message from log"
3. "actual commit message from log"
All commits will follow: [LANGUAGE] + [STYLE]
All commits will follow: [DOMINANT_LANGUAGE_OR_SCRIPT] + [STYLE]
\`\`\`
**IF YOU SKIP THIS OUTPUT, YOUR COMMITS WILL BE WRONG. STOP AND REDO.**
@@ -435,17 +435,19 @@ git log -1 --oneline
**Based on COMMIT_CONFIG from Phase 1:**
\`\`\`
IF style == SEMANTIC AND language == KOREAN:
-> "feat: 로그인 기능 추가"
IF style == SEMANTIC AND language == ENGLISH:
-> "feat: add login feature"
IF style == PLAIN AND language == KOREAN:
-> "로그인 기능 추가"
IF style == PLAIN AND language == ENGLISH:
-> "Add login feature"
IF style == SEMANTIC:
-> Use a semantic prefix + repository language message
-> Examples:
- "feat: add login feature"
- "feat: ログイン機能を追加"
- "feat: 로그인 기능 추가"
IF style == PLAIN:
-> Use plain repository language message without semantic prefix
-> Examples:
- "Add login feature"
- "ログイン機能を追加"
- "로그인 기능 추가"
IF style == SHORT:
-> "format" / "type fix" / "lint"
@@ -453,7 +455,7 @@ IF style == SHORT:
**VALIDATION before each commit:**
1. Does message match detected style?
2. Does language match detected language?
2. Does message use the repository's dominant language/script profile (from Phase 1.1)?
3. Is it similar to examples from git log?
If ANY check fails -> REWRITE message.
@@ -7,12 +7,12 @@ export const GIT_MASTER_HISTORY_SEARCH_WORKFLOW_SECTION = `## HISTORY SEARCH MOD
| User Request | Search Type | Tool |
|--------------|-------------|------|
| "when was X added" / "X가 언제 추가됐어" | PICKAXE | \`git log -S\` |
| "when was X added" in any language (e.g., "X가 언제 추가됐어", "Xはいつ追加された") | PICKAXE | \`git log -S\` |
| "find commits changing X pattern" | REGEX | \`git log -G\` |
| "who wrote this line" / "이 줄 누가 썼어" | BLAME | \`git blame\` |
| "when did bug start" / "버그 언제 생겼어" | BISECT | \`git bisect\` |
| "history of file" / "파일 히스토리" | FILE_LOG | \`git log -- path\` |
| "find deleted code" / "삭제된 코드 찾기" | PICKAXE_ALL | \`git log -S --all\` |
| "who wrote this line" in any language (e.g., "이 줄 누가 썼어", "この行を書いたのは誰") | BLAME | \`git blame\` |
| "when did bug start" in any language (e.g., "버그 언제 생겼어", "バグはいつ入った") | BISECT | \`git bisect\` |
| "history of file" in any language (e.g., "파일 히스토리", "ファイル履歴") | FILE_LOG | \`git log -- path\` |
| "find deleted code" in any language (e.g., "삭제된 코드 찾기", "削除されたコードを探す") | PICKAXE_ALL | \`git log -S --all\` |
### H1.2 Extract Search Parameters
@@ -13,9 +13,9 @@ Analyze the user's request to determine operation mode:
| User Request Pattern | Mode | Jump To |
|---------------------|------|---------|
| "commit", "커밋", changes to commit | \`COMMIT\` | Phase 0-6 (existing) |
| "rebase", "리베이스", "squash", "cleanup history" | \`REBASE\` | Phase R1-R4 |
| "find when", "who changed", "언제 바뀌었", "git blame", "bisect" | \`HISTORY_SEARCH\` | Phase H1-H3 |
| Commit intent in any language (e.g., "commit", "커밋", "コミット") | \`COMMIT\` | Phase 0-6 (existing) |
| Rebase/squash intent in any language (e.g., "rebase", "리베이스", "リベース") | \`REBASE\` | Phase R1-R4 |
| History lookup intent in any language (e.g., "find when", "언제 바뀌었", "いつ追加") | \`HISTORY_SEARCH\` | Phase H1-H3 |
| "smart rebase", "rebase onto" | \`REBASE\` | Phase R1-R4 |
**CRITICAL**: Don't default to COMMIT mode. Parse the actual request.
@@ -5,7 +5,7 @@ export const GIT_MASTER_QUICK_REFERENCE_SECTION = `## Quick Reference
| If git log shows... | Use this style |
|---------------------|----------------|
| \`feat: xxx\`, \`fix: yyy\` | SEMANTIC |
| \`Add xxx\`, \`Fix yyy\`, \`xxx 추가\` | PLAIN |
| \`Add xxx\`, \`Fix yyy\`, \`xxx 추가\`, \`xxxを追加\` | PLAIN |
| \`format\`, \`lint\`, \`typo\` | SHORT |
| Full sentences | SENTENCE |
| Mix of above | Use MAJORITY (not semantic by default) |
@@ -30,19 +30,19 @@ git stash list
\`\`\`
USER REQUEST -> STRATEGY:
"squash commits" / "cleanup" / "정리"
"squash commits" intent in any language (e.g., "cleanup", "정리", "履歴整理")
-> INTERACTIVE_SQUASH
"rebase on main" / "update branch" / "메인에 리베이스"
"rebase on main" intent in any language (e.g., "update branch", "메인에 리베이스", "mainにリベース")
-> REBASE_ONTO_BASE
"autosquash" / "apply fixups"
-> AUTOSQUASH
"reorder commits" / "커밋 순서"
"reorder commits" intent in any language (e.g., "커밋 순서", "コミット順を並べ替え")
-> INTERACTIVE_REORDER
"split commit" / "커밋 분리"
"split commit" intent in any language (e.g., "커밋 분리", "コミット分割")
-> INTERACTIVE_EDIT
\`\`\`
</rebase_context>
@@ -114,7 +114,7 @@ describe("team-layout-tmux", () => {
return null
}
return { sessionId: displaySessionId }
return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" }
})
})
@@ -149,7 +149,7 @@ describe("team-layout-tmux", () => {
expect(runTmuxCommandMock).toHaveBeenCalledTimes(0)
})
test("creates detached focus and grid windows and sends attach via send-keys", async () => {
test("creates teammate panes in the caller window and sends attach via send-keys", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -162,12 +162,8 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
const newWindowCalls = commands.filter((args) => args[0] === "new-window")
expect(newWindowCalls.length).toBe(2)
expect(newWindowCalls.map((args) => args[args.indexOf("-n") + 1])).toEqual([
"team-run-attach-focus",
"team-run-attach-grid",
])
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(2)
const sendKeysCalls = commands.filter((args) => args[0] === "send-keys")
const literals = sendKeysCalls.map((args) => args.join(" "))
@@ -175,7 +171,7 @@ describe("team-layout-tmux", () => {
expect(literals.some((s) => s.includes("--session 's-m2'"))).toBe(true)
})
test("uses focus main-vertical and grid tiled windows", async () => {
test("uses caller window main-vertical layout with caller pane as primary", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -191,14 +187,14 @@ describe("team-layout-tmux", () => {
const commands = getCommands()
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
expect(selectLayoutArgs).toContain("main-vertical")
expect(selectLayoutArgs).toContain("tiled")
expect(commands).toContainEqual(["set-window-option", "-t", "@1", "main-pane-width", "60%"])
expect(selectLayoutArgs).not.toContain("tiled")
expect(commands).toContainEqual(["resize-pane", "-t", process.env.TMUX_PANE ?? "", "-x", "30%"])
expect(result).not.toBeNull()
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2", "m3"])
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
})
test("#given 4 or more teammates #when createTeamLayout runs #then it still keeps separate focus and grid windows", async () => {
test("#given 4 or more teammates #when createTeamLayout runs #then it keeps every teammate in the caller window", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = Array.from({ length: 5 }, (_, index) => ({
@@ -212,13 +208,11 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
const newWindowNames = commands
.filter((args) => args[0] === "new-window")
.map((args) => args[args.indexOf("-n") + 1])
expect(newWindowNames).toEqual(["team-run-tiled-focus", "team-run-tiled-grid"])
expect(commands.some((args) => args[0] === "new-window")).toBe(false)
expect(commands.filter((args) => args[0] === "split-window")).toHaveLength(5)
const selectLayoutArgs = commands.filter((args) => args[0] === "select-layout").map((args) => args[args.length - 1])
expect(selectLayoutArgs).toContain("main-vertical")
expect(selectLayoutArgs).toContain("tiled")
expect(selectLayoutArgs).not.toContain("tiled")
})
test("#given caller inside tmux #when createTeamLayout runs #then it never steals focus or mutates window border options", async () => {
@@ -333,7 +327,7 @@ describe("team-layout-tmux", () => {
})
describe("createTeamLayout - focus/grid window topology", () => {
test("#given caller inside tmux #when createTeamLayout runs #then creates focus and grid windows without a new session", async () => {
test("#given caller inside tmux #when createTeamLayout runs #then uses the caller window without a new session", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -347,8 +341,8 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
expect(commands.some((args) => args[0] === "new-session")).toBe(false)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(false)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
expect(commands.some((args) => args[0] === "split-window" && args.includes(process.env.TMUX_PANE ?? ""))).toBe(true)
})
test("#given caller session resolved #when createTeamLayout runs #then ownedSession is false", async () => {
@@ -364,7 +358,7 @@ describe("team-layout-tmux", () => {
expect(result?.ownedSession).toBe(false)
})
test("#given first teammate #when layout runs #then it creates focus and grid windows without splitting the leader pane", async () => {
test("#given first teammate #when layout runs #then it splits the caller pane horizontally for teammate area", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [{ name: "m1", sessionId: "s-m1", worktreePath: "/tmp/m1" }]
@@ -375,8 +369,10 @@ describe("team-layout-tmux", () => {
// then
const commands = getCommands()
const splitCalls = commands.filter((args) => args[0] === "split-window")
expect(splitCalls).toEqual([])
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
expect(splitCalls).toEqual([
["split-window", "-t", process.env.TMUX_PANE ?? "", "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", "/tmp/m1"],
])
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
})
test("#given 3 members #when createTeamLayout runs #then focusPanesByMember contains 3 distinct pane ids", async () => {
@@ -397,7 +393,7 @@ describe("team-layout-tmux", () => {
expect(new Set(Object.values(result?.focusPanesByMember ?? {})).size).toBe(3)
})
test("#given layout created #when createTeamLayout runs #then it keeps separate focus and grid pane maps", async () => {
test("#given layout created #when createTeamLayout runs #then it records focus panes only", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
@@ -412,9 +408,10 @@ describe("team-layout-tmux", () => {
const commands = getCommands()
expect(result).not.toBeNull()
expect(Object.keys(result?.focusPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
expect(Object.keys(result?.gridPanesByMember ?? {}).sort()).toEqual(["m1", "m2"])
expect(result?.focusWindowId).not.toBe(result?.gridWindowId)
expect(commands.filter((args) => args[0] === "new-window").length).toBe(2)
expect(Object.keys(result?.gridPanesByMember ?? {})).toEqual([])
expect(result?.focusWindowId).toBe("test-session:0")
expect(result?.gridWindowId).toBeUndefined()
expect(commands.filter((args) => args[0] === "new-window").length).toBe(0)
expect(commands.some((args) => args[0] === "send-keys" && args.includes("Enter"))).toBe(true)
})
})
@@ -9,7 +9,7 @@ type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string
export type TeamLayoutResult = {
focusWindowId: string
gridWindowId: string
gridWindowId?: string
focusPanesByMember: Record<string, string>
gridPanesByMember: Record<string, string>
targetSessionId: string
@@ -34,60 +34,63 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
}
async function listPanesInWindow(tmuxPath: string, windowId: string): Promise<Array<string>> {
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowId, "-F", "#{pane_id}"])
async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise<Array<string>> {
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
if (!result.success || !result.output) return []
return result.output.trim().split("\n").filter(Boolean)
}
async function createTeamWindow(
function selectExistingTeammatePane(teammatePanes: Array<string>, callerPaneId: string): string {
return teammatePanes[Math.floor(teammatePanes.length / 2)] ?? teammatePanes[teammatePanes.length - 1] ?? callerPaneId
}
function buildSplitArgs(callerPaneId: string, teammatePanes: Array<string>, member: TeamLayoutMember): Array<string> {
if (teammatePanes.length === 0) {
return ["split-window", "-t", callerPaneId, "-h", "-l", "70%", "-P", "-F", "#{pane_id}", "-c", getPaneWorkingDirectory(member)]
}
return [
"split-window",
"-t",
selectExistingTeammatePane(teammatePanes, callerPaneId),
teammatePanes.length % 2 === 1 ? "-v" : "-h",
"-P",
"-F",
"#{pane_id}",
"-c",
getPaneWorkingDirectory(member),
]
}
async function createTeamLayoutInCallerWindow(
tmuxPath: string,
targetSessionId: string,
windowName: string,
layout: "main-vertical" | "tiled",
callerPaneId: string,
windowTarget: string,
members: Array<TeamLayoutMember>,
serverUrl: string,
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
const [firstMember, ...restMembers] = members
if (!firstMember) return null
const created = await runTmuxCommand(tmuxPath, [
"new-window", "-d", "-P", "-F", "#{window_id}", "-t", targetSessionId, "-n", windowName,
"-c", getPaneWorkingDirectory(firstMember),
])
if (!created.success || !created.output) return null
const windowId = created.output.trim()
const initialPanes = await listPanesInWindow(tmuxPath, windowId)
const firstPaneId = initialPanes[0]
if (!firstPaneId) return null
const panesByMember: Record<string, string> = { [firstMember.name]: firstPaneId }
for (const member of restMembers) {
const split = await runTmuxCommand(tmuxPath, [
"split-window", "-d", "-P", "-F", "#{pane_id}", "-t", firstPaneId,
"-c", getPaneWorkingDirectory(member),
])
if (!split.success || !split.output) return null
panesByMember[member.name] = split.output.trim()
}
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout])
if (!layoutResult.success) return null
if (layout === "main-vertical") {
await runTmuxCommand(tmuxPath, ["set-window-option", "-t", windowId, "main-pane-width", "60%"])
await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowId, layout])
}
): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
const panesByMember: Record<string, string> = {}
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
for (const member of members) {
const paneId = panesByMember[member.name]
if (!paneId) return null
const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
if (!split.success || !split.output) return null
const paneId = split.output.trim()
teammatePanes = [...teammatePanes, paneId]
panesByMember[member.name] = paneId
await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
}
return { windowId, panesByMember }
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
if (!layoutResult.success) return null
const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
if (!resizeResult.success) return null
return { focusWindowId: windowTarget, focusPanesByMember: panesByMember }
}
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
@@ -111,27 +114,21 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
}
const callerSession = await resolveCallerTmuxSession(tmuxPath)
const fallbackSessionName = `omo-team-${teamRunId}`
const ownedSession = callerSession === null
const targetSessionId = callerSession?.sessionId ?? fallbackSessionName
if (ownedSession) {
log("falling back to detached team session because caller tmux session could not be resolved", { teamRunId })
const created = await runTmuxCommand(tmuxPath, ["new-session", "-d", "-s", fallbackSessionName, "-P", "-F", "#{window_id}"])
if (!created.success || !created.output) return null
if (!callerSession) {
log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
return null
}
const focus = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-focus`, "main-vertical", members, serverUrl)
const grid = await createTeamWindow(tmuxPath, targetSessionId, `team-${teamRunId}-grid`, "tiled", members, serverUrl)
if (!focus || !grid) return null
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
if (!focus) return null
return {
focusWindowId: focus.windowId,
gridWindowId: grid.windowId,
focusPanesByMember: focus.panesByMember,
gridPanesByMember: grid.panesByMember,
targetSessionId,
ownedSession,
focusWindowId: focus.focusWindowId,
gridWindowId: undefined,
focusPanesByMember: focus.focusPanesByMember,
gridPanesByMember: {},
targetSessionId: callerSession.sessionId,
ownedSession: false,
}
} catch (error) {
log("tmux visualization unavailable, skipping", { error: String(error) })
@@ -23,7 +23,7 @@ type TmuxManagerLike = {
type TeamLayoutResultLike = {
focusWindowId: string
gridWindowId: string
gridWindowId?: string
focusPanesByMember: Record<string, string>
gridPanesByMember: Record<string, string>
targetSessionId: string
@@ -79,7 +79,7 @@ function isTeamLayoutResultLike(value: unknown): value is TeamLayoutResultLike {
}
return typeof value.focusWindowId === "string"
&& typeof value.gridWindowId === "string"
&& (value.gridWindowId === undefined || typeof value.gridWindowId === "string")
&& isRecord(value.focusPanesByMember)
&& isRecord(value.gridPanesByMember)
&& typeof value.targetSessionId === "string"
@@ -210,6 +210,7 @@ async function invokeRemoveTeamLayout(
targetSessionId,
focusWindowId: layoutResult.focusWindowId,
gridWindowId: layoutResult.gridWindowId,
paneIds: Object.values(layoutResult.focusPanesByMember),
},
tmuxManager,
]))
@@ -272,13 +273,11 @@ describe("team-mode live tmux smoke", () => {
await rm(state.tempRoot, { recursive: true, force: true })
})
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then two new windows appear in the caller session AND removeTeamLayout deletes exactly those two windows leaving the caller session intact", async () => {
test.skipIf(!LIVE)("#given a real caller tmux session and two mock members #when createTeamLayout runs #then teammate panes appear in the caller window and cleanup leaves the session intact", async () => {
// given
const state = requireLiveTestState()
const layoutModule = await loadLayoutModule()
const teamRunId = randomUUID()
const shortTeamRunId = teamRunId.slice(0, 8)
const expectedWindowNames = [`focus-${shortTeamRunId}`, `grid-${shortTeamRunId}`]
const initialWindows = await listWindows(state.callerSessionId)
const members: TeamLayoutMemberLike[] = [
{
@@ -295,25 +294,33 @@ describe("team-mode live tmux smoke", () => {
// when
const layoutResult = await invokeCreateTeamLayout(layoutModule, teamRunId, members, state.tmuxManager)
const windowsAppeared = await waitForCondition(async () => {
const panesAppeared = await waitForCondition(async () => {
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => panes.stdout.split("\n").includes(paneId))
})
const windowsUnchangedBeforeCleanup = await waitForCondition(async () => {
const windows = await listWindows(state.callerSessionId)
return expectedWindowNames.every((windowName) => windows.some((window) => window.name === windowName))
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
})
await invokeRemoveTeamLayout(layoutModule, teamRunId, state.tmuxManager, layoutResult, state.callerSessionId)
const windowsRemoved = await waitForCondition(async () => {
const panesRemoved = await waitForCondition(async () => {
const panes = await runTmuxCommand(["list-panes", "-t", state.callerSessionId, "-F", "#{pane_id}"])
return panes.success && Object.values(layoutResult.focusPanesByMember).every((paneId) => !panes.stdout.split("\n").includes(paneId))
})
const windowsUnchangedAfterCleanup = await waitForCondition(async () => {
const windows = await listWindows(state.callerSessionId)
const noExpectedWindowsRemain = expectedWindowNames.every((windowName) => windows.every((window) => window.name !== windowName))
const sameWindowIds = windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
return noExpectedWindowsRemain && sameWindowIds
return windows.map((window) => window.id).join(",") === initialWindows.map((window) => window.id).join(",")
})
const callerSessionStillAlive = await runTmuxCommand(["has-session", "-t", state.callerSessionId])
// then
expect(layoutResult.focusWindowId.length).toBeGreaterThan(0)
expect(layoutResult.gridWindowId.length).toBeGreaterThan(0)
expect(windowsAppeared).toBe(true)
expect(windowsRemoved).toBe(true)
expect(layoutResult.gridWindowId).toBeUndefined()
expect(panesAppeared).toBe(true)
expect(windowsUnchangedBeforeCleanup).toBe(true)
expect(panesRemoved).toBe(true)
expect(windowsUnchangedAfterCleanup).toBe(true)
expect(callerSessionStillAlive.success).toBe(true)
expect(process.env.TMUX_PANE).toBe(state.callerPaneId)
})
@@ -18,7 +18,7 @@ function shellSingleQuote(value: string): string {
return `'${value.split("'").join(`'"'"'`)}'`
}
async function createTmuxStub(options: { stdout: string; exitCode: number }): Promise<TmuxStub> {
async function createTmuxStub(options: { stdout: string; windowStdout?: string; exitCode: number }): Promise<TmuxStub> {
const directory = await mkdtemp(path.join(tmpdir(), "resolve-caller-tmux-session-"))
temporaryDirectories.push(directory)
@@ -27,7 +27,7 @@ async function createTmuxStub(options: { stdout: string; exitCode: number }): Pr
const script = [
"#!/bin/sh",
`printf '%s\\n' \"$@\" >> ${shellSingleQuote(logPath)}`,
`printf '%s' ${shellSingleQuote(options.stdout)}`,
`case "$*" in *'#{session_name}:#{window_index}'*) printf '%s' ${shellSingleQuote(options.windowStdout ?? options.stdout)} ;; *) printf '%s' ${shellSingleQuote(options.stdout)} ;; esac`,
`exit ${options.exitCode}`,
].join("\n")
@@ -67,17 +67,20 @@ describe("resolveCallerTmuxSession", () => {
expect(await readLogLines(stub.logPath)).toHaveLength(0)
})
test("#given TMUX_PANE=%42 and display returns '$7' #when resolve runs #then returns { sessionId: '$7' }", async () => {
test("#given TMUX_PANE=%42 and display returns session and window #when resolve runs #then returns caller tmux target", async () => {
// given
process.env.TMUX_PANE = "%42"
const stub = await createTmuxStub({ stdout: "$7", exitCode: 0 })
const stub = await createTmuxStub({ stdout: "$7", windowStdout: "test-session:0", exitCode: 0 })
// when
const result = await resolveCallerTmuxSession(stub.tmuxPath)
// then
expect(result).toEqual({ sessionId: "$7" })
expect(await readLogLines(stub.logPath)).toEqual(["display", "-p", "-F", "#{session_id}", "-t", "%42"])
expect(result).toEqual({ sessionId: "$7", paneId: "%42", windowTarget: "test-session:0" })
expect(await readLogLines(stub.logPath)).toEqual([
"display", "-p", "-F", "#{session_id}", "-t", "%42",
"display", "-p", "-F", "#{session_name}:#{window_index}", "-t", "%42",
])
})
test("#given TMUX_PANE=%42 and display returns 'garbage' #when resolve runs #then returns null", async () => {
@@ -2,9 +2,12 @@ import { runTmuxCommand } from "../../../shared/tmux"
type ResolvedCallerTmuxSession = {
sessionId: string
paneId: string
windowTarget: string
}
const TMUX_SESSION_ID_PATTERN = /^\$[0-9]+$/
const TMUX_WINDOW_TARGET_PATTERN = /^[^:]+:[0-9]+$/
export async function resolveCallerTmuxSession(tmuxPath: string): Promise<ResolvedCallerTmuxSession | null> {
const callerPaneId = process.env.TMUX_PANE
@@ -12,15 +15,25 @@ export async function resolveCallerTmuxSession(tmuxPath: string): Promise<Resolv
return null
}
const result = await runTmuxCommand(tmuxPath, ["display", "-p", "-F", "#{session_id}", "-t", callerPaneId])
if (!result.success) {
const sessionResult = await runTmuxCommand(tmuxPath, ["display", "-p", "-F", "#{session_id}", "-t", callerPaneId])
if (!sessionResult.success) {
return null
}
const sessionId = result.output.trim()
const sessionId = sessionResult.output.trim()
if (!TMUX_SESSION_ID_PATTERN.test(sessionId)) {
return null
}
return { sessionId }
const windowResult = await runTmuxCommand(tmuxPath, ["display", "-p", "-F", "#{session_name}:#{window_index}", "-t", callerPaneId])
if (!windowResult.success) {
return null
}
const windowTarget = windowResult.output.trim()
if (!TMUX_WINDOW_TARGET_PATTERN.test(windowTarget)) {
return null
}
return { sessionId, paneId: callerPaneId, windowTarget }
}
@@ -86,7 +86,7 @@ export async function deleteTeam(
}
}
const removedLayout = tmuxMgr !== undefined && canVisualize()
const removedLayout = config.tmux_visualization && tmuxMgr !== undefined && canVisualize()
if (removedLayout) {
const memberPaneIds = runtimeState.members
.filter((member) => member.agentType !== "leader" && member.tmuxPaneId)
@@ -305,7 +305,7 @@ describe("team-runtime shutdown", () => {
// when
const result = await deleteTeam(
fixture.teamRunId,
fixture.config,
{ ...fixture.config, tmux_visualization: true },
{ getServerUrl: () => "http://localhost" } as never,
undefined,
{ force: true },
@@ -325,6 +325,29 @@ describe("team-runtime shutdown", () => {
)
})
test("#given tmux manager but visualization disabled #when deleteTeam runs #then layout cleanup is skipped", async () => {
// given
const fixture = await createFixture()
temporaryDirectories.push(fixture.baseDir)
spyOn(layoutModule, "canVisualize").mockReturnValue(true)
const removeLayoutSpy = spyOn(layoutModule, "removeTeamLayout").mockResolvedValue(undefined)
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
"member-a": "shutdown_approved",
"member-b": "completed",
})
// when
const result = await deleteTeam(
fixture.teamRunId,
{ ...fixture.config, tmux_visualization: false },
{ getServerUrl: () => "http://localhost" } as never,
)
// then
expect(result.removedLayout).toBe(false)
expect(removeLayoutSpy).not.toHaveBeenCalled()
})
test("cancels team background tasks before deleting when force=true", async () => {
// given
const fixture = await createFixture()
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, type BoulderState, upsertTaskSessionState } from "../../features/boulder-state"
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
@@ -40,7 +40,7 @@ export async function syncBackgroundLaunchSessionTracking(input: {
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
pendingTaskRef,
boulderState.active_plan,
resolveBoulderPlanPath(ctx.directory, boulderState),
)
if (currentTask && !shouldSkipTaskSessionUpdate) {
+7 -2
View File
@@ -4,6 +4,7 @@ import {
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
} from "../../features/boulder-state"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getLastAgentFromSession } from "./session-last-agent"
@@ -52,8 +53,12 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
const currentTask = currentBoulder
? readCurrentTopLevelTask(currentBoulder.active_plan)
&& currentPlanPath
? readCurrentTopLevelTask(currentPlanPath)
: null
const preferredTaskSession = currentTask
? getTaskSessionState(input.ctx.directory, currentTask.key)
@@ -163,7 +168,7 @@ function scheduleRetry(input: {
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const canContinueSession = await canContinueTrackedBoulderSession({
+37
View File
@@ -1494,6 +1494,43 @@ session_id: ses_untrusted_999
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
// given
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "worktree-complete-plan.md")
mkdirSync(join(TEST_DIR, ".sisyphus", "plans"), { recursive: true })
mkdirSync(join(worktreeDir, ".sisyphus", "plans"), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n")
writeBoulderState(TEST_DIR, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "worktree-complete-plan",
worktree_path: worktreeDir,
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
try {
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then
expect(mockInput._promptMock).not.toHaveBeenCalled()
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
test("should skip when abort error occurred before idle", async () => {
// given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -96,4 +96,39 @@ describe("resolveActiveBoulderSession", () => {
expect(result?.progress.isComplete).toBe(false)
expect(result?.boulderState.session_ids).toContain("ses_appended")
})
test("returns complete progress when a mirrored worktree plan is complete", async () => {
// given
const mainPlanPath = join(testDirectory, ".sisyphus", "plans", "worktree-plan.md")
const worktreeDirectory = join(tmpdir(), `resolve-active-boulder-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDirectory, ".sisyphus", "plans", "worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n", "utf-8")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: mainPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_tracked"],
session_origins: { ses_tracked: "direct" },
plan_name: "worktree-plan",
worktree_path: worktreeDirectory,
})
try {
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_tracked",
})
// then
expect(result).not.toBeNull()
expect(result?.progress.isComplete).toBe(true)
expect(result?.progress.completed).toBe(1)
} finally {
rmSync(worktreeDirectory, { recursive: true, force: true })
}
})
})
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
export async function resolveActiveBoulderSession(input: {
@@ -20,7 +20,7 @@ export async function resolveActiveBoulderSession(input: {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
}
+5 -3
View File
@@ -4,6 +4,7 @@ import {
getPlanProgress,
getTaskSessionState,
readBoulderState,
resolveBoulderPlanPath,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -98,12 +99,13 @@ export function createToolExecuteAfterHandler(input: {
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) {
const progress = getPlanProgress(boulderState.active_plan)
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
const progress = getPlanProgress(planPath)
const {
currentTask,
shouldSkipTaskSessionUpdate,
shouldIgnoreCurrentSessionId,
} = resolveTaskContext(pendingTaskRef, boulderState.active_plan)
} = resolveTaskContext(pendingTaskRef, planPath)
const trackedTaskSession = currentTask
? getTaskSessionState(ctx.directory, currentTask.key)
: null
@@ -136,7 +138,7 @@ export function createToolExecuteAfterHandler(input: {
const originalResponse = toolOutput.output
const shouldPauseForApproval = sessionState
? shouldPauseForFinalWaveApproval({
planPath: boulderState.active_plan,
planPath,
taskOutput: originalResponse,
sessionState,
})
+2 -2
View File
@@ -2,7 +2,7 @@ import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils"
import type { PluginInput } from "@opencode-ai/plugin"
import { readBoulderState, readCurrentTopLevelTask } from "../../features/boulder-state"
import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state"
import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
@@ -60,7 +60,7 @@ export function createToolExecuteBeforeHandler(input: {
} else {
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(boulderState.active_plan)
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
: null
if (currentTask) {
const task = {
@@ -26,6 +26,10 @@ export async function processFilePathForAgentsInjection(input: {
sessionID: string;
output: { title: string; output: string; metadata: unknown };
}): Promise<void> {
// Guard: output.output may be non-string at runtime (e.g. MCP bridge format changes).
// Consistent with the pattern used in tool-output-truncator and other hooks.
if (typeof input.output.output !== "string") return;
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
if (!resolved) return;
+1 -1
View File
@@ -163,7 +163,7 @@ describe("model fallback hook", () => {
expect(secondOutput.message["model"]).toEqual({
providerID: "opencode-go",
modelID: "kimi-k2.5",
modelID: "kimi-k2.6",
})
expect(secondOutput.message["variant"]).toBeUndefined()
})
@@ -88,7 +88,6 @@ describe("ralph-loop non-abort error continuation", () => {
expect(messagesCalls.length).toBeGreaterThan(0)
expect(hook.getState()?.iteration).toBe(2)
})
test("continues ultrawork loop immediately after non-abort session error", async () => {
// given - an active ULW Loop receives a recoverable runtime error
const hook = createRalphLoopHook({
@@ -132,7 +132,8 @@ export function classifyErrorType(error: unknown): string | undefined {
/exhausted\s+your\s+capacity/i.test(message) ||
/out\s+of\s+credits?/i.test(message) ||
/payment.?required/i.test(message) ||
/usage\s+limit/i.test(message)
/usage\s+limit/i.test(message) ||
/credit\s+balance.*too\s+low/i.test(message)
) {
return "quota_exceeded"
}
+11 -4
View File
@@ -7,6 +7,7 @@ import {
getPlanName,
getPlanProgress,
readBoulderState,
resolveBoulderPlanPath,
writeBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -150,7 +151,8 @@ function buildExistingSessionContext(params: {
directory: string
}): string {
const { existingState, sessionId, activeAgent, worktreePath, worktreeBlock, directory } = params
const progress = getPlanProgress(existingState.active_plan)
const planPath = resolveBoulderPlanPath(directory, existingState)
const progress = getPlanProgress(planPath)
if (progress.isComplete) {
return `
## Previous Work Complete
@@ -186,7 +188,7 @@ Looking for new plans...`
**Status**: RESUMING existing work
**Plan**: ${existingState.plan_name}
**Path**: ${existingState.active_plan}
**Path**: ${planPath}
**Progress**: ${progress.completed}/${progress.total} tasks completed
**Sessions**: ${existingState.session_ids.length + 1} (current session appended)
**Started**: ${existingState.started_at}
@@ -197,11 +199,16 @@ Read the plan file and continue from the first unchecked task.`
}
function shouldDiscoverPlans(
directory: string,
existingState: ReturnType<typeof readBoulderState>,
explicitPlanName: string | null,
): boolean {
return (!existingState && !explicitPlanName)
|| (existingState !== null && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)
|| (
existingState !== null
&& !explicitPlanName
&& getPlanProgress(resolveBoulderPlanPath(directory, existingState)).isComplete
)
}
function buildPlanDiscoveryContext(params: {
@@ -303,7 +310,7 @@ export function buildStartWorkContextInfo(params: {
})
}
if (shouldDiscoverPlans(existingState, explicitPlanName)) {
if (shouldDiscoverPlans(ctx.directory, existingState, explicitPlanName)) {
return buildPlanDiscoveryContext({
contextInfo,
sessionId,
+35 -1
View File
@@ -2,7 +2,7 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { dirname, join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createStartWorkHook } from "./index"
@@ -1013,5 +1013,39 @@ You are starting a Sisyphus work session.
expect(output.parts[0].text).toContain("subagent")
expect(output.parts[0].text).not.toContain("Worktree Setup Required")
})
test("should show worktree plan progress and path when the mirrored plan exists", async () => {
// given
const mainPlanPath = join(testDir, ".sisyphus", "plans", "resume-worktree-plan.md")
const worktreeDir = join(testDir, "..", `resume-worktree-${randomUUID()}`)
const worktreePlanPath = join(worktreeDir, ".sisyphus", "plans", "resume-worktree-plan.md")
mkdirSync(dirname(mainPlanPath), { recursive: true })
mkdirSync(dirname(worktreePlanPath), { recursive: true })
writeFileSync(mainPlanPath, "# Plan\n- [ ] Main repo task\n")
writeFileSync(worktreePlanPath, "# Plan\n- [x] Worktree task 1\n- [ ] Worktree task 2\n")
writeBoulderState(testDir, {
active_plan: mainPlanPath,
started_at: "2026-01-01T00:00:00Z",
session_ids: ["old-session"],
plan_name: "resume-worktree-plan",
worktree_path: worktreeDir,
})
const hook = createStartWorkHook(createMockPluginInput())
const output = {
parts: [{ type: "text", text: createStartWorkPrompt() }],
}
try {
// when
await hook["chat.message"]({ sessionID: "session-worktree-progress" }, output)
// then
expect(output.parts[0].text).toContain(worktreePlanPath)
expect(output.parts[0].text).toContain("1/2 tasks completed")
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
})
})
})
@@ -13,6 +13,45 @@ import { handleSessionIdle } from "./idle-event"
import { handleNonIdleEvent } from "./non-idle-events"
import { isTokenLimitError } from "./token-limit-detection"
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined
}
function getStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key]
return typeof value === "string" && value.length > 0 ? value : undefined
}
function extractSessionErrorInfo(error: unknown): { name?: string; message?: string } | undefined {
if (!error) return undefined
if (typeof error === "string") return { message: error }
if (error instanceof Error) return { name: error.name, message: error.message }
const root = asRecord(error)
if (!root) return { message: String(error) }
const data = asRecord(root.data)
const nestedError = asRecord(root.error)
const dataError = asRecord(data?.error)
const name = getStringField(root, "name")
?? getStringField(data, "name")
?? getStringField(nestedError, "name")
?? getStringField(dataError, "name")
const messageParts = [
getStringField(root, "message"),
getStringField(data, "message"),
getStringField(nestedError, "message"),
getStringField(dataError, "message"),
getStringField(root, "code"),
getStringField(nestedError, "code"),
getStringField(dataError, "code"),
].filter((message): message is string => typeof message === "string")
return { name, message: messageParts.join(" ") || undefined }
}
export function createTodoContinuationHandler(args: {
ctx: PluginInput
sessionStateStore: SessionStateStore
@@ -35,7 +74,8 @@ export function createTodoContinuationHandler(args: {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const error = props?.error as { name?: string; message?: string } | undefined
const error = extractSessionErrorInfo(props?.error)
let shouldCancelCountdown = false
if (error?.name === "MessageAbortedError" || error?.name === "AbortError") {
const state = sessionStateStore.getState(sessionID)
state.wasCancelled = true
@@ -45,14 +85,18 @@ export function createTodoContinuationHandler(args: {
state.awaitingPostInjectionProgressCheck = false
state.stagnationCount = 0
state.consecutiveFailures = 0
shouldCancelCountdown = true
log(`[${HOOK_NAME}] Abort detected via session.error`, { sessionID, errorName: error.name })
} else if (isTokenLimitError(error)) {
const state = sessionStateStore.getState(sessionID)
state.tokenLimitDetected = true
shouldCancelCountdown = true
log(`[${HOOK_NAME}] Token limit error detected via session.error`, { sessionID, errorName: error?.name, errorMessage: error?.message })
}
sessionStateStore.cancelCountdown(sessionID)
if (shouldCancelCountdown) {
sessionStateStore.cancelCountdown(sessionID)
}
log(`[${HOOK_NAME}] session.error`, { sessionID })
return
}
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "."
type PromptCall = {
sessionID: string
text: string
}
type PromptInput = {
path: { id: string }
body: { parts: Array<{ text: string }> }
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function createPluginInput(promptCalls: PromptCall[]): Parameters<typeof createTodoContinuationEnforcer>[0] {
return {
directory: "/tmp/opencode-overload-continuation-test",
client: {
session: {
todo: async () => ({
data: [
{ id: "1", content: "Keep working", status: "pending", priority: "high" },
],
}),
messages: async () => ({ data: [] }),
promptAsync: async (input: PromptInput) => {
promptCalls.push({
sessionID: input.path.id,
text: input.body.parts[0]?.text ?? "",
})
return {}
},
},
tui: {
showToast: async () => ({}),
},
},
} as Parameters<typeof createTodoContinuationEnforcer>[0]
}
describe("todo-continuation-enforcer OpenCode overload errors", () => {
test(
"#given countdown is armed #when OpenCode reports server_is_overloaded #then continuation still injects",
async () => {
// given
const sessionID = "main-opencode-overload"
const promptCalls: PromptCall[] = []
_resetForTesting()
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createPluginInput(promptCalls))
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// when
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: {
type: "error",
sequence_number: 2,
error: {
type: "service_unavailable_error",
code: "server_is_overloaded",
message: "Our servers are currently overloaded. Please try again later.",
param: null,
},
},
},
},
})
await wait(2500)
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.sessionID).toBe(sessionID)
expect(promptCalls[0]?.text).toContain("TODO CONTINUATION")
},
{ timeout: 10000 },
)
})
+330 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
@@ -560,7 +560,6 @@ describe("loadPluginConfig", () => {
git_env_prefix: "GIT_MASTER=1",
})
})
describe("team_mode.tmux_visualization", () => {
it("#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false", async () => {
// given
@@ -639,4 +638,332 @@ describe("loadPluginConfig", () => {
expect(config.team_mode).toBeUndefined()
})
})
it("should merge configs from ancestor directories with closer winning", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(
join(userConfigDir, "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "user/model" } } })
)
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "home/model" } } })
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "work/model" } } })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "project/model" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then
expect(config.agents?.oracle?.model).toBe("project/model")
})
it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "home/oracle" } } })
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { hephaestus: { model: "work/hephaestus" } } })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { sisyphus: { model: "project/sisyphus" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - each level contributes a non-conflicting field
expect(config.agents?.oracle?.model).toBe("home/oracle")
expect(config.agents?.hephaestus?.model).toBe("work/hephaestus")
expect(config.agents?.sisyphus?.model).toBe("project/sisyphus")
})
it("should preserve mcp_env_allowlist as user-only when ancestors set their own allowlists", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-allowlist-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(
join(userConfigDir, "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["USER_ONLY_TOKEN"] })
)
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["HOME_TOKEN"] })
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["WORK_TOKEN"] })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["PROJECT_TOKEN"] })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - only the canonical user config can extend the allowlist
expect(config.mcp_env_allowlist).toEqual(["USER_ONLY_TOKEN"])
})
it("should stop walking at $HOME and ignore configs above it", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-stop-"))
const userConfigDir = join(rootDir, "user-config")
const aboveHomeDir = join(rootDir, "above-home")
const homeDir = join(aboveHomeDir, "home")
const projectDir = join(homeDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(aboveHomeDir, ".opencode"), { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(aboveHomeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "above-home/leak" } } })
)
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { hephaestus: { model: "home/wins" } } })
)
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - $HOME's config applies, but the directory above it does NOT
expect(config.agents?.hephaestus?.model).toBe("home/wins")
expect(config.agents?.oracle).toBeUndefined()
})
it("should not walk above the start directory when start is outside $HOME", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-outside-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const outsideHomeRoot = join(rootDir, "outside-home")
const projectDir = join(outsideHomeRoot, "proj")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(homeDir, { recursive: true })
mkdirSync(join(outsideHomeRoot, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(outsideHomeRoot, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "outside-home/leak" } } })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { hephaestus: { model: "project/wins" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - project loads, but the parent above it (outside $HOME) is not walked into
expect(config.agents?.hephaestus?.model).toBe("project/wins")
expect(config.agents?.oracle).toBeUndefined()
})
it("should merge git_master overrides across ancestors with closer winning", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-git-master-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({
git_master: {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "HOME=1",
},
})
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({
git_master: {
include_co_authored_by: true,
},
})
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({
git_master: {
commit_footer: true,
},
})
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then project's commit_footer wins, work's include_co_authored_by wins,
// home's git_env_prefix is preserved since nobody else set it
expect(config.git_master).toEqual({
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "HOME=1",
})
})
it("should resolve agent_definitions relative to each ancestor's own .opencode directory", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-agent-defs-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
const workDefRelativePath = "./work-agent.md"
const projectDefRelativePath = "./project-agent.md"
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agent_definitions: [workDefRelativePath] })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agent_definitions: [projectDefRelativePath] })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then each ancestor's relative path resolves against its own .opencode/
expect(config.agent_definitions).toContain(join(realpathSync(workDir), ".opencode", "work-agent.md"))
expect(config.agent_definitions).toContain(join(realpathSync(projectDir), ".opencode", "project-agent.md"))
})
it("should migrate legacy basenames found in ancestor directories", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-legacy-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
const ancestorLegacyPath = join(workDir, ".opencode", "oh-my-opencode.jsonc")
const ancestorCanonicalPath = join(workDir, ".opencode", "oh-my-openagent.jsonc")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
ancestorLegacyPath,
JSON.stringify({ agents: { oracle: { model: "ancestor-legacy/model" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then
expect(existsSync(ancestorLegacyPath)).toBe(false)
expect(existsSync(ancestorCanonicalPath)).toBe(true)
expect(config.agents?.oracle?.model).toBe("ancestor-legacy/model")
})
})
+95 -53
View File
@@ -1,19 +1,50 @@
import * as fs from "fs";
import { homedir } from "node:os";
import * as path from "path";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import {
log,
containsPath,
deepMerge,
getOpenCodeConfigDir,
addConfigLoadError,
parseJsonc,
detectPluginConfigFile,
findProjectOpencodePluginConfigFiles,
migrateConfigFile,
resolveAgentDefinitionPaths,
} from "./shared";
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
function resolveHomeDirectory(): string {
// Read env vars directly to bypass os.homedir() caching. Bun caches the
// first os.homedir() result, which means tests that set process.env.HOME
// after import never see the new value. Production behaviour is preserved
// because HOME (or USERPROFILE on Windows) is set by the OS at startup.
return process.env.HOME ?? process.env.USERPROFILE ?? homedir()
}
function resolveConfigPathAfterLegacyMigration(detectedPath: string): string {
if (!path.basename(detectedPath).startsWith(LEGACY_CONFIG_BASENAME)) {
return detectedPath
}
const migrated = migrateLegacyConfigFile(detectedPath)
const canonicalPath = path.join(
path.dirname(detectedPath),
`${CONFIG_BASENAME}${path.extname(detectedPath)}`,
)
// Only switch to canonical path if migration succeeded OR canonical file already exists
if (migrated || fs.existsSync(canonicalPath)) {
return canonicalPath
}
// Otherwise keep loading from the legacy path that was detected
return detectedPath
}
function loadExplicitGitMasterOverrides(configPath: string): Record<string, unknown> | undefined {
try {
if (!fs.existsSync(configPath)) {
@@ -214,47 +245,39 @@ export function loadPluginConfig(
}
// Auto-copy legacy config file to canonical name if needed
if (userDetected.format !== "none" && path.basename(userDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
const migrated = migrateLegacyConfigFile(userDetected.path);
const canonicalPath = path.join(
path.dirname(userDetected.path),
`${CONFIG_BASENAME}${path.extname(userDetected.path)}`
);
// Only switch to canonical path if migration succeeded OR canonical file already exists
if (migrated || fs.existsSync(canonicalPath)) {
userConfigPath = canonicalPath;
}
// Otherwise keep loading from the legacy path that was detected
if (userDetected.format !== "none") {
userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath)
}
// Project-level config path - prefer .jsonc over .json
const projectBasePath = path.join(directory, ".opencode");
const projectDetected = detectPluginConfigFile(projectBasePath);
let projectConfigPath =
projectDetected.format !== "none"
? projectDetected.path
: path.join(projectBasePath, `${CONFIG_BASENAME}.json`);
// Pin the walk to $HOME only when the start directory is inside it. Outside
// $HOME the walker would otherwise reach FS root and surface unrelated configs
// in /tmp, /opt, etc.
const homeDirectory = resolveHomeDirectory()
const stopDirectory = containsPath(homeDirectory, directory) ? homeDirectory : directory
const ancestorConfigPathsNearestFirst = findProjectOpencodePluginConfigFiles(
directory,
stopDirectory,
)
log("Walked ancestor plugin configs", {
paths: ancestorConfigPathsNearestFirst,
count: ancestorConfigPathsNearestFirst.length,
stopDirectory,
})
if (projectDetected.legacyPath) {
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
canonicalPath: projectDetected.path,
legacyPath: projectDetected.legacyPath,
});
}
// Auto-copy legacy project config file to canonical name if needed
if (projectDetected.format !== "none" && path.basename(projectDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
const projectMigrated = migrateLegacyConfigFile(projectDetected.path);
const canonicalProjectPath = path.join(
path.dirname(projectDetected.path),
`${CONFIG_BASENAME}${path.extname(projectDetected.path)}`
);
// Only switch to canonical path if migration succeeded OR canonical file already exists
if (projectMigrated || fs.existsSync(canonicalProjectPath)) {
projectConfigPath = canonicalProjectPath;
}
// Otherwise keep loading from the legacy path that was detected
}
// Migrate any legacy basenames among ancestors and warn on dual-config presence
const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map(
(ancestorPath) => {
const opencodeDir = path.dirname(ancestorPath)
const ancestorDetected = detectPluginConfigFile(opencodeDir)
if (ancestorDetected.legacyPath) {
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
canonicalPath: ancestorDetected.path,
legacyPath: ancestorDetected.legacyPath,
})
}
return resolveConfigPathAfterLegacyMigration(ancestorPath)
},
)
// Load user config first (base). Parse empty config through Zod to apply field defaults.
const userConfig = loadConfigFromPath(userConfigPath, ctx)
@@ -271,34 +294,53 @@ export function loadPluginConfig(
let config: OhMyOpenCodeConfig =
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
// Override with project config
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath)
const ancestorGitMasterOverridesFarthestFirst: Array<Record<string, unknown>> = []
if (projectConfig?.agent_definitions) {
projectConfig.agent_definitions = resolveAgentDefinitionPaths(
projectConfig.agent_definitions,
projectBasePath,
directory
)
for (const ancestorPath of canonicalAncestorPathsFarthestFirst) {
const ancestorConfig = loadConfigFromPath(ancestorPath, ctx)
const ancestorOverrides = loadExplicitGitMasterOverrides(ancestorPath)
if (ancestorConfig?.agent_definitions) {
// Resolve relative paths against this ancestor's own .opencode/ base.
const ancestorBasePath = path.dirname(ancestorPath)
const ancestorDir = path.dirname(ancestorBasePath)
ancestorConfig.agent_definitions = resolveAgentDefinitionPaths(
ancestorConfig.agent_definitions,
ancestorBasePath,
ancestorDir,
)
}
if (ancestorConfig) {
config = mergeConfigs(config, ancestorConfig)
}
if (ancestorOverrides) {
ancestorGitMasterOverridesFarthestFirst.push(ancestorOverrides)
}
}
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
if (userGitMasterOverrides || projectGitMasterOverrides) {
if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
const mergedAncestorGitMaster: Record<string, unknown> = {}
for (const override of ancestorGitMasterOverridesFarthestFirst) {
Object.assign(mergedAncestorGitMaster, override)
}
config = {
...config,
git_master: {
...defaultGitMaster,
...(userGitMasterOverrides ?? {}),
...(projectGitMasterOverrides ?? {}),
...mergedAncestorGitMaster,
},
}
}
// Security: mcp_env_allowlist remains user-only across the entire walk.
// This prevents clone-and-load attacks where a malicious project (or any
// walked ancestor) could extend the env var allowlist used during ${VAR}
// expansion in .mcp.json files. See commit 316d2504 for context.
config = {
...config,
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
@@ -103,12 +103,12 @@ describe("buildPrometheusAgentConfig", () => {
expect(result).toBeDefined();
});
test("accepts glm-5 from fallback chain", async () => {
test("accepts glm-5.1 from fallback chain", async () => {
const result = await buildPrometheusAgentConfig({
configAgentPlan: undefined,
pluginPrometheusOverride: undefined,
userCategories: undefined,
currentModel: "opencode-go/glm-5",
currentModel: "opencode-go/glm-5.1",
});
expect(result).toBeDefined();
});
+3 -3
View File
@@ -222,7 +222,7 @@ describe("createEventHandler - model fallback", () => {
expect(promptCalls).toEqual([sessionID])
expect(output.message["model"]).toMatchObject({
providerID: "opencode-go",
modelID: "kimi-k2.5",
modelID: "kimi-k2.6",
})
expect(output.message["variant"]).toBeUndefined()
})
@@ -549,14 +549,14 @@ describe("createEventHandler - model fallback", () => {
//#then - first fallback entry applied (no-op skip: claude-opus-4-7 matches current model after normalization)
expect(first.message["model"]).toMatchObject({
providerID: "opencode-go",
modelID: "kimi-k2.5",
modelID: "kimi-k2.6",
})
expect(first.message["variant"]).toBeUndefined()
//#when - second retry cycle
const second = await triggerRetryCycle()
//#then - second fallback entry applied (chain advanced past opencode-go/kimi-k2.5)
//#then - second fallback entry applied (chain advanced past opencode-go/kimi-k2.6)
expect(second.message["model"]).toMatchObject({
providerID: "kimi-for-coding",
modelID: "k2p5",
+34 -1
View File
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { pathToFileURL } from "node:url"
import { tool } from "@opencode-ai/plugin"
import { normalizeToolArgSchemas } from "./normalize-tool-arg-schemas"
import { normalizeToolArgSchemas, sanitizeJsonSchema } from "./normalize-tool-arg-schemas"
const tempDirectories: string[] = []
@@ -95,3 +95,36 @@ describe("normalizeToolArgSchemas", () => {
expect(afterQuery?.examples).toEqual(["issue 2314"])
})
})
describe("sanitizeJsonSchema", () => {
it("rewrites bare $ref values to $defs JSON pointers", () => {
// given
const schema = {
type: "object",
properties: {
new_encoding: { $ref: "Encoding" },
existing_pointer: { $ref: "#/$defs/AlreadyValid" },
},
$defs: {
Encoding: { type: "string" },
AlreadyValid: { type: "string" },
},
}
// when
const sanitized = sanitizeJsonSchema(schema)
// then
expect(sanitized).toEqual({
type: "object",
properties: {
new_encoding: { $ref: "#/$defs/Encoding" },
existing_pointer: { $ref: "#/$defs/AlreadyValid" },
},
$defs: {
Encoding: { type: "string" },
AlreadyValid: { type: "string" },
},
})
})
})
+13
View File
@@ -47,6 +47,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function normalizeJsonSchemaRef(value: string): string {
if (value.startsWith("#") || value.includes(":") || value.startsWith("/")) {
return value
}
return `#/$defs/${value}`
}
export function sanitizeJsonSchema(value: unknown, depth = 0, isPropertyName = false): unknown {
if (Array.isArray(value)) {
return value.map((item) => sanitizeJsonSchema(item, depth + 1, false))
@@ -67,6 +75,11 @@ export function sanitizeJsonSchema(value: unknown, depth = 0, isPropertyName = f
continue
}
if (!isPropertyName && key === "$ref" && typeof nestedValue === "string") {
sanitized[key] = normalizeJsonSchemaRef(nestedValue)
continue
}
const childIsPropertyName = key === "properties" && !isPropertyName
sanitized[key] = sanitizeJsonSchema(nestedValue, depth + 1, childIsPropertyName)
}
+9
View File
@@ -181,5 +181,14 @@ export function createToolExecuteAfterHandler(args: {
}
await runToolExecuteAfterHooks()
// Cap excessively long error outputs that would flood the TUI with raw
// stack traces or framework internals. Normal outputs are handled by the
// tool-output-truncator hook for specific tools; this catch-all only fires
// for outputs that still exceed a safe display length after all hooks.
const MAX_ERROR_OUTPUT_CHARS = 3000
if (typeof output.output === "string" && output.output.length > MAX_ERROR_OUTPUT_CHARS) {
output.output = output.output.slice(0, MAX_ERROR_OUTPUT_CHARS) + "\n\n...(output truncated for display)"
}
}
}
+2
View File
@@ -287,6 +287,8 @@ export function createToolRegistry(args: {
browserProvider: skillContext.browserProvider,
teamModeEnabled: pluginConfig.team_mode?.enabled ?? false,
nativeSkills: "skills" in ctx ? (ctx as { skills: SkillLoadOptions["nativeSkills"] }).skills : undefined,
pluginsEnabled: pluginConfig.claude_code?.plugins ?? true,
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
})
const taskSystemEnabled = isTaskSystemEnabled(pluginConfig)
+4
View File
@@ -214,6 +214,10 @@ describe("stripAgentListSortPrefix", () => {
it("strips legacy zero-width sort prefixes baked into v3.14.0v3.16.0 sessions", () => {
expect(stripAgentListSortPrefix("\u200B\u200BHephaestus - Deep Agent")).toBe("Hephaestus - Deep Agent")
})
it("strips leading and trailing wrapper characters after sort prefix removal", () => {
expect(stripAgentListSortPrefix("\\Hephaestus - Deep Agent\\")).toBe("Hephaestus - Deep Agent")
})
})
describe("normalizeAgentForPrompt", () => {
+2 -1
View File
@@ -28,13 +28,14 @@ export const AGENT_DISPLAY_NAMES: Record<string, string> = {
const INVISIBLE_AGENT_CHARACTERS_REGEX = /[\u200B\u200C\u200D\uFEFF]/g
const VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX = /^\d+\|/
const AGENT_WRAPPER_CHARS_REGEX = /^[\\/"']+|[\\/"']+$/g
export function stripInvisibleAgentCharacters(agentName: string): string {
return agentName.replace(INVISIBLE_AGENT_CHARACTERS_REGEX, "")
}
export function stripAgentListSortPrefix(agentName: string): string {
return stripInvisibleAgentCharacters(agentName).replace(VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX, "")
return stripInvisibleAgentCharacters(agentName).replace(VISIBLE_AGENT_LIST_SORT_PREFIX_REGEX, "").replace(AGENT_WRAPPER_CHARS_REGEX, "")
}
/**
+1 -1
View File
@@ -36,7 +36,7 @@ describe("resolveAgentVariant", () => {
sisyphus: { category: "ultrabrain" },
},
categories: {
ultrabrain: { model: "openai/gpt-5.4", variant: "xhigh" },
ultrabrain: { model: "openai/gpt-5.5", variant: "xhigh" },
},
} as OhMyOpenCodeConfig
@@ -1,6 +1,21 @@
import type { ModelCapabilitiesSnapshotEntry } from "./types"
export const SUPPLEMENTAL_MODEL_CAPABILITIES: Record<string, ModelCapabilitiesSnapshotEntry> = {
"kimi-k2.6": {
id: "kimi-k2.6",
family: "kimi",
reasoning: true,
temperature: true,
toolCall: true,
modalities: {
input: ["text", "image", "video"],
output: ["text"],
},
limit: {
context: 262144,
output: 262144,
},
},
"gpt-5.5": {
id: "gpt-5.5",
family: "gpt",
@@ -129,4 +129,14 @@ describe("model-capability-aliases", () => {
ruleID: "claude-thinking-legacy-alias",
})
})
test("treats claude-opus-4-6-thinking as canonical, not as a legacy alias", () => {
const result = resolveModelIDAlias("claude-opus-4-6-thinking")
expect(result).toEqual({
requestedModelID: "claude-opus-4-6-thinking",
canonicalModelID: "claude-opus-4-6-thinking",
source: "canonical",
})
})
})
+2 -2
View File
@@ -53,8 +53,8 @@ const EXACT_ALIAS_RULES_BY_MODEL: ReadonlyMap<string, ExactAliasRule> = new Map(
const PATTERN_ALIAS_RULES: ReadonlyArray<PatternAliasRule> = [
{
ruleID: "claude-thinking-legacy-alias",
description: "Normalizes legacy Claude Opus thinking suffixes (4-6, 4-7) to the canonical snapshot ID.",
match: (normalizedModelID) => /^claude-opus-4-(?:6|7)-thinking$/.test(normalizedModelID),
description: "Normalizes the legacy claude-opus-4-7-thinking id to the canonical snapshot ID.",
match: (normalizedModelID) => /^claude-opus-4-7-thinking$/.test(normalizedModelID),
canonicalize: () => "claude-opus-4-7",
},
{
+1 -1
View File
@@ -32,7 +32,7 @@ export const HEURISTIC_MODEL_FAMILY_REGISTRY: ReadonlyArray<HeuristicModelFamily
family: "gpt-5",
includes: ["gpt-5"],
variants: ["low", "medium", "high", "xhigh"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh"],
reasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
},
{
family: "gpt-legacy",
+36 -28
View File
@@ -41,7 +41,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const second = sisyphus.fallbackChain[1]
expect(second.providers).toEqual(["opencode-go", "vercel"])
expect(second.model).toBe("kimi-k2.5")
expect(second.model).toBe("kimi-k2.6")
const third = sisyphus.fallbackChain[2]
expect(third.providers).toEqual(["kimi-for-coding"])
@@ -72,27 +72,31 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
// then - fallbackChain exists with openai/gpt-5.4-mini-fast as first entry
expect(librarian).toBeDefined()
expect(librarian.fallbackChain).toBeArray()
expect(librarian.fallbackChain).toHaveLength(5)
expect(librarian.fallbackChain).toHaveLength(6)
const primary = librarian.fallbackChain[0]
expect(primary.providers).toEqual(["openai"])
expect(primary.model).toBe("gpt-5.4-mini-fast")
const second = librarian.fallbackChain[1]
expect(second.providers[0]).toBe("opencode-go")
expect(second.model).toBe("minimax-m2.7-highspeed")
expect(second.providers).toContain("opencode-go")
expect(second.model).toBe("qwen3.5-plus")
const tertiary = librarian.fallbackChain[2]
expect(tertiary.providers[0]).toBe("opencode-go")
expect(tertiary.model).toBe("minimax-m2.7")
const third = librarian.fallbackChain[2]
expect(third.providers).toEqual(["vercel"])
expect(third.model).toBe("minimax-m2.7-highspeed")
const quaternary = librarian.fallbackChain[3]
expect(quaternary.providers).toContain("anthropic")
expect(quaternary.model).toBe("claude-haiku-4-5")
expect(quaternary.providers).toContain("opencode-go")
expect(quaternary.model).toBe("minimax-m2.7")
const fifth = librarian.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.4-nano")
const quinary = librarian.fallbackChain[4]
expect(quinary.providers).toContain("anthropic")
expect(quinary.model).toBe("claude-haiku-4-5")
const sixth = librarian.fallbackChain[5]
expect(sixth.providers).toContain("openai")
expect(sixth.model).toBe("gpt-5.4-nano")
})
test("explore has valid fallbackChain with openai/gpt-5.4-mini-fast as primary", () => {
@@ -102,7 +106,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
// when - accessing explore requirement
expect(explore).toBeDefined()
expect(explore.fallbackChain).toBeArray()
expect(explore.fallbackChain).toHaveLength(5)
expect(explore.fallbackChain).toHaveLength(6)
const primary = explore.fallbackChain[0]
expect(primary.providers).toEqual(["openai"])
@@ -110,19 +114,23 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const secondary = explore.fallbackChain[1]
expect(secondary.providers).toContain("opencode-go")
expect(secondary.model).toBe("minimax-m2.7-highspeed")
expect(secondary.model).toBe("qwen3.5-plus")
const tertiary = explore.fallbackChain[2]
expect(tertiary.providers).toContain("opencode-go")
expect(tertiary.model).toBe("minimax-m2.7")
const third = explore.fallbackChain[2]
expect(third.providers).toEqual(["vercel"])
expect(third.model).toBe("minimax-m2.7-highspeed")
const quaternary = explore.fallbackChain[3]
expect(quaternary.providers).toContain("anthropic")
expect(quaternary.model).toBe("claude-haiku-4-5")
expect(quaternary.providers).toContain("opencode-go")
expect(quaternary.model).toBe("minimax-m2.7")
const fifth = explore.fallbackChain[4]
expect(fifth.providers).toContain("openai")
expect(fifth.model).toBe("gpt-5.4-nano")
const quinary = explore.fallbackChain[4]
expect(quinary.providers).toContain("anthropic")
expect(quinary.model).toBe("claude-haiku-4-5")
const sixth = explore.fallbackChain[5]
expect(sixth.providers).toContain("openai")
expect(sixth.model).toBe("gpt-5.4-nano")
})
test("multimodal-looker has valid fallbackChain with gpt-5.5 as primary", () => {
@@ -130,7 +138,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
// when - accessing multimodal-looker requirement
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.5 -> glm-4.6v -> gpt-5-nano
// then - fallbackChain: gpt-5.5 -> opencode-go/kimi-k2.6 -> glm-4.6v -> gpt-5-nano
expect(multimodalLooker).toBeDefined()
expect(multimodalLooker.fallbackChain).toBeArray()
expect(multimodalLooker.fallbackChain).toHaveLength(4)
@@ -142,7 +150,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
const secondary = multimodalLooker.fallbackChain[1]
expect(secondary.providers).toEqual(["opencode-go", "vercel"])
expect(secondary.model).toBe("kimi-k2.5")
expect(secondary.model).toBe("kimi-k2.6")
const tertiary = multimodalLooker.fallbackChain[2]
expect(tertiary.model).toBe("glm-4.6v")
@@ -222,7 +230,7 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("anthropic")
const secondary = atlas.fallbackChain[1]
expect(secondary.model).toBe("kimi-k2.5")
expect(secondary.model).toBe("kimi-k2.6")
expect(secondary.providers[0]).toBe("opencode-go")
const tertiary = atlas.fallbackChain[2]
@@ -345,7 +353,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// when - accessing visual-engineering requirement
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5 → k2p5
// then - fallbackChain: gemini-3.1-pro(high) → glm-5 → opus-4-6(max) → opencode-go/glm-5.1 → k2p5
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain).toHaveLength(5)
@@ -365,7 +373,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
const fourth = visualEngineering.fallbackChain[3]
expect(fourth.providers[0]).toBe("opencode-go")
expect(fourth.model).toBe("glm-5")
expect(fourth.model).toBe("glm-5.1")
const fifth = visualEngineering.fallbackChain[4]
expect(fifth.providers[0]).toBe("kimi-for-coding")
@@ -458,7 +466,7 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("google")
const second = writing.fallbackChain[1]
expect(second.model).toBe("kimi-k2.5")
expect(second.model).toBe("kimi-k2.6")
expect(second.providers[0]).toBe("opencode-go")
const third = writing.fallbackChain[2]
+17 -15
View File
@@ -25,7 +25,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{
providers: [
@@ -72,13 +72,14 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
librarian: {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
@@ -87,7 +88,8 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
explore: {
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4-mini-fast" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go"], model: "qwen3.5-plus" },
{ providers: ["vercel"], model: "minimax-m2.7-highspeed" },
{ providers: ["opencode-go", "vercel"], model: "minimax-m2.7" },
{ providers: ["anthropic", "opencode", "vercel"], model: "claude-haiku-4-5" },
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.4-nano" },
@@ -96,7 +98,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"multimodal-looker": {
fallbackChain: [
{ providers: ["openai", "opencode", "vercel"], model: "gpt-5.5", variant: "medium" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{ providers: ["zai-coding-plan", "vercel"], model: "glm-4.6v" },
{ providers: ["openai", "github-copilot", "opencode", "vercel"], model: "gpt-5-nano" },
],
@@ -113,7 +115,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3.1-pro",
@@ -132,7 +134,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gpt-5.5",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
@@ -153,13 +155,13 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gemini-3.1-pro",
variant: "high",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
atlas: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
@@ -171,7 +173,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"sisyphus-junior": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode", "vercel"], model: "claude-sonnet-4-6" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["openai", "github-copilot", "opencode", "vercel"],
model: "gpt-5.5",
@@ -197,7 +199,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
],
},
@@ -218,7 +220,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "claude-opus-4-7",
variant: "max",
},
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
],
},
deep: {
@@ -284,7 +286,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
model: "gpt-5.3-codex",
variant: "medium",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
@@ -306,7 +308,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
{ providers: ["zai-coding-plan", "opencode", "vercel"], model: "glm-5" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5" },
{ providers: ["opencode-go", "vercel"], model: "glm-5.1" },
{ providers: ["opencode", "vercel"], model: "kimi-k2.5" },
{
providers: [
@@ -328,7 +330,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
providers: ["google", "github-copilot", "opencode", "vercel"],
model: "gemini-3-flash",
},
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.5" },
{ providers: ["opencode-go", "vercel"], model: "kimi-k2.6" },
{
providers: ["anthropic", "github-copilot", "opencode", "vercel"],
model: "claude-sonnet-4-6",
+5 -5
View File
@@ -32,10 +32,10 @@ export type ModelSettingsCompatibilityChange = {
from: string
to?: string
reason:
| "unsupported-by-model-family"
| "unknown-model-family"
| "unsupported-by-model-metadata"
| "max-output-limit"
| "unsupported-by-model-family"
| "unknown-model-family"
| "unsupported-by-model-metadata"
| "max-output-limit"
}
export type ModelSettingsCompatibilityResult = {
@@ -49,7 +49,7 @@ export type ModelSettingsCompatibilityResult = {
}
const VARIANT_LADDER = ["low", "medium", "high", "xhigh", "max"]
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh"]
const REASONING_LADDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
function downgradeWithinLadder(value: string, allowed: string[], ladder: string[]): string | undefined {
const requestedIndex = ladder.indexOf(value)
+91 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, realpathSync, rmSync } from "node:fs"
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
@@ -121,4 +121,94 @@ describe("project-discovery-dirs", () => {
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
})
it("#given nested .opencode plugin config files #when finding plugin config files #then returns nearest-first canonical paths", async () => {
// given
const grandparentDir = join(TEST_DIR, "grandparent")
const parentDir = join(grandparentDir, "parent")
const projectDir = join(parentDir, "project")
mkdirSync(join(grandparentDir, ".opencode"), { recursive: true })
mkdirSync(join(parentDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(parentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(parentDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc")),
])
})
it("#given a stop directory #when finding plugin config files #then walking halts at the stop boundary inclusive", async () => {
// given
const stopDir = join(TEST_DIR, "stop")
const childDir = join(stopDir, "child")
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
mkdirSync(join(stopDir, ".opencode"), { recursive: true })
mkdirSync(join(childDir, ".opencode"), { recursive: true })
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(stopDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(childDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(childDir, stopDir)
// then
expect(paths).toEqual([
canonicalPath(join(childDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(stopDir, ".opencode", "oh-my-openagent.jsonc")),
])
})
it("#given a legacy basename in an ancestor #when finding plugin config files #then detection picks up the legacy path", async () => {
// given
const projectDir = join(TEST_DIR, "project")
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc"), "{}")
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc")),
])
})
it("#given no .opencode directories along the walk #when finding plugin config files #then returns an empty list", async () => {
// given
const projectDir = join(TEST_DIR, "project", "deep")
mkdirSync(projectDir, { recursive: true })
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([])
})
})
+34
View File
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
import { existsSync, realpathSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import { detectPluginConfigFile } from "./jsonc-parser"
const worktreePathCache = new Map<string, string | undefined>()
function normalizePath(path: string): string {
@@ -114,3 +116,35 @@ export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirec
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
export function findProjectOpencodePluginConfigFiles(
startDirectory: string,
stopDirectory?: string,
): string[] {
const paths: string[] = []
const seen = new Set<string>()
let currentDirectory = normalizePath(startDirectory)
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
while (true) {
const opencodeDirectory = join(currentDirectory, ".opencode")
if (existsSync(opencodeDirectory)) {
const detected = detectPluginConfigFile(opencodeDirectory)
if (detected.format !== "none" && !seen.has(detected.path)) {
seen.add(detected.path)
paths.push(detected.path)
}
}
if (resolvedStopDirectory === currentDirectory) {
return paths
}
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory) {
return paths
}
currentDirectory = normalizePath(parentDirectory)
}
}
+55
View File
@@ -0,0 +1,55 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { AST_GREP_REPLACE_DESCRIPTION, AST_GREP_SEARCH_DESCRIPTION } from "./tool-descriptions"
const runSgMock = mock(async () => ({
matches: [],
totalMatches: 0,
truncated: false,
}))
mock.module("./cli", () => ({
runSg: runSgMock,
}))
import { createAstGrepTools } from "./tools"
describe("createAstGrepTools", () => {
beforeEach(() => {
runSgMock.mockClear()
})
it("#given the production tool factory #when creating tools #then exposes shared ast-grep descriptions", () => {
// given / when
const tools = createAstGrepTools({ directory: "/repo" } as never)
// then
expect(tools.ast_grep_search.description).toBe(AST_GREP_SEARCH_DESCRIPTION)
expect(tools.ast_grep_replace.description).toBe(AST_GREP_REPLACE_DESCRIPTION)
expect(tools.ast_grep_search.description).toContain("NOT regex")
})
it("#given empty search results from a regex-shaped pattern #when executing #then appends the pattern hint", async () => {
// given
const tools = createAstGrepTools({ directory: "/repo" } as never)
// when
const output = await tools.ast_grep_search.execute(
{ pattern: "foo|bar", lang: "typescript" },
{},
)
// then
expect(output).toContain("No matches found")
expect(output).toContain("alternation")
expect(output).toContain("grep")
expect(runSgMock).toHaveBeenCalledWith({
pattern: "foo|bar",
lang: "typescript",
paths: ["/repo"],
globs: undefined,
context: undefined,
})
})
})
+10 -35
View File
@@ -3,6 +3,12 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
import { getPatternHint } from "./pattern-hints"
import {
AST_GREP_REPLACE_DESCRIPTION,
AST_GREP_SEARCH_DESCRIPTION,
AST_GREP_SEARCH_PATTERN_PARAM,
} from "./tool-descriptions"
import type { CliLanguage } from "./types"
async function showOutputToUser(context: unknown, output: string): Promise<void> {
@@ -12,39 +18,11 @@ async function showOutputToUser(context: unknown, output: string): Promise<void>
await ctx.metadata?.({ metadata: { output } })
}
function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
const src = pattern.trim()
if (lang === "python") {
if (src.startsWith("class ") && src.endsWith(":")) {
const withoutColon = src.slice(0, -1)
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
}
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
const withoutColon = src.slice(0, -1)
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
}
}
if (["javascript", "typescript", "tsx"].includes(lang)) {
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
}
}
return null
}
export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
const ast_grep_search: ToolDefinition = tool({
description:
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
"IMPORTANT: Patterns must be complete AST nodes (valid code). " +
"For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
description: AST_GREP_SEARCH_DESCRIPTION,
args: {
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
pattern: tool.schema.string().describe(AST_GREP_SEARCH_PATTERN_PARAM),
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
@@ -63,7 +41,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
let output = formatSearchResult(result)
if (result.matches.length === 0 && !result.error) {
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
const hint = getPatternHint(args.pattern, args.lang as CliLanguage)
if (hint) {
output += `\n\n${hint}`
}
@@ -80,10 +58,7 @@ export function createAstGrepTools(ctx: PluginInput): Record<string, ToolDefinit
})
const ast_grep_replace: ToolDefinition = tool({
description:
"Replace code patterns across filesystem with AST-aware rewriting. " +
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
"Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
description: AST_GREP_REPLACE_DESCRIPTION,
args: {
pattern: tool.schema.string().describe("AST pattern to match"),
rewrite: tool.schema.string().describe("Replacement pattern (can use $VAR from pattern)"),
@@ -1,5 +1,11 @@
/// <reference types="bun-types" />
import { describe, test, expect, mock } from "bun:test"
mock.module("../../shared/frontmatter", () => ({
parseFrontmatter: () => ({ frontmatter: {}, content: "" }),
}))
mock.module("js-yaml", () => ({
load: () => ({}),
}))
import type { BackgroundManager } from "../../features/background-agent"
import type { PluginInput } from "@opencode-ai/plugin"
import { executeBackground } from "./background-executor"
@@ -100,6 +106,35 @@ describe("executeBackground", () => {
expect(launchArgs.fallbackChain).toEqual(fallbackChain)
})
test("sanitizes subagent_type before passing to background manager launch", async () => {
//#given
const wrappedArgs = {
...testArgs,
subagent_type: "\\hephaestus\\",
}
launchMock.mockResolvedValueOnce({
id: "test-task-id",
sessionId: "sub-session",
description: "Test task",
agent: "hephaestus",
status: "pending",
})
//#when
await executeBackground(wrappedArgs, testContext, mockManager, mockClient)
//#then
const latestCall = [...launchMock.mock.calls].pop()
if (!latestCall) {
throw new Error("Expected background manager launch to be called")
}
const launchArgs = latestCall[0]
if (!launchArgs) {
throw new Error("Expected launch arguments")
}
expect(launchArgs.agent).toBe("hephaestus")
})
test("keeps launched background task alive when parent aborts before session id resolves", async () => {
//#given - parent abort after launch should stop waiting, not fail the background task
const abortController = new AbortController()
@@ -8,6 +8,7 @@ import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { getMessageDir } from "./message-dir"
import { getSessionTools } from "../../shared/session-tools-store"
import { sanitizeSubagentType } from "../delegate-task/subagent-discovery"
export async function executeBackground(
args: CallOmoAgentArgs,
@@ -47,7 +48,7 @@ export async function executeBackground(
const task = await manager.launch({
description: args.description,
prompt: args.prompt,
agent: args.subagent_type,
agent: sanitizeSubagentType(args.subagent_type),
parentSessionId: toolContext.sessionID,
parentMessageId: toolContext.messageID,
parentAgent,
+13
View File
@@ -136,6 +136,19 @@ describe("createCallOmoAgent", () => {
})
describe("dynamic custom agent resolution", () => {
test("should reject missing subagent_type without throwing", async () => {
const mockCtx = createMockCtx(DEFAULT_AGENTS)
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
const result = await executeFunc(
{ description: "Test", prompt: "Fix bug", run_in_background: true },
toolCtx
)
expect(result).toContain("subagent_type is required")
})
test("should accept a custom agent returned by client.app.agents()", async () => {
const agents = [...DEFAULT_AGENTS, { name: "bug-fixer", mode: "subagent" }]
const mockCtx = createMockCtx(agents)
+4
View File
@@ -140,6 +140,10 @@ export function createCallOmoAgent(
`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`,
);
if (typeof args.subagent_type !== "string" || args.subagent_type.trim() === "") {
return "Error: subagent_type is required."
}
const callableAgents = await resolveCallableAgents(ctx.client);
// Strip ZWSP and case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
+5 -5
View File
@@ -272,7 +272,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["opencode-go"], model: "kimi-k2.6" },
]
//#when
@@ -282,10 +282,10 @@ describe("executeSyncTask - cleanup on error paths", () => {
//#then
expect(result).toContain("Task completed")
expect(result).toContain("Model: opencode-go/kimi-k2.5")
expect(result).toContain("Model: opencode-go/kimi-k2.6")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
])
})
@@ -339,7 +339,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
}
const fallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["opencode-go"], model: "kimi-k2.5" },
{ providers: ["opencode-go"], model: "kimi-k2.6" },
{ providers: ["openai"], model: "gpt-5.4", variant: "medium" },
]
@@ -352,7 +352,7 @@ describe("executeSyncTask - cleanup on error paths", () => {
expect(result).toBe("Final failure")
expect(attemptedModels).toEqual([
{ providerID: "anthropic", modelID: "claude-opus-4-7", variant: "max" },
{ providerID: "opencode-go", modelID: "kimi-k2.5", variant: undefined },
{ providerID: "opencode-go", modelID: "kimi-k2.6", variant: undefined },
{ providerID: "openai", modelID: "gpt-5.4", variant: "medium" },
])
})
@@ -5,29 +5,29 @@ describe("buildMultimodalLookerFallbackChain", () => {
// given
const { buildMultimodalLookerFallbackChain } = await import("./multimodal-fallback-chain")
const visionCapableModels = [
{ providerID: "openai", modelID: "gpt-5.4" },
{ providerID: "opencode", modelID: "gpt-5.4" },
{ providerID: "openai", modelID: "gpt-5.5" },
{ providerID: "opencode", modelID: "gpt-5.5" },
]
// when
const result = buildMultimodalLookerFallbackChain(visionCapableModels)
// then
const gpt54Entries = result.filter((entry) => entry.model === "gpt-5.4")
expect(gpt54Entries.length).toBeGreaterThan(0)
const gpt55Entries = result.filter((entry) => entry.model === "gpt-5.5")
expect(gpt55Entries.length).toBeGreaterThan(0)
})
it("avoids duplicates when adding hardcoded entries", async () => {
// given
const { buildMultimodalLookerFallbackChain } = await import("./multimodal-fallback-chain")
const visionCapableModels = [{ providerID: "openai", modelID: "gpt-5.4" }]
const visionCapableModels = [{ providerID: "openai", modelID: "gpt-5.5" }]
// when
const result = buildMultimodalLookerFallbackChain(visionCapableModels)
// then
expect(result.length).toBeGreaterThan(0)
expect(result[0].model).toBe("gpt-5.4")
expect(result[0].model).toBe("gpt-5.5")
expect(result[0].providers).toContain("openai")
})