Merge remote-tracking branch 'origin/dev' into fix/cli-run-premature-exit-with-background-tasks

This commit is contained in:
柯杨
2026-04-22 09:45:56 +08:00
390 changed files with 49705 additions and 41270 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# src/ — Plugin Source
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
@@ -10,7 +10,7 @@ Entry point `index.ts` orchestrates 5-step initialization: loadConfig → create
| File | Purpose |
|------|---------|
| `index.ts` | Plugin entry, exports `OhMyOpenCodePlugin` |
| `index.ts` | Plugin entry, default-exports `pluginModule: PluginModule` with `{ id, server }` |
| `plugin-config.ts` | JSONC parse, multi-level merge, Zod v4 validation |
| `create-managers.ts` | TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler |
| `create-tools.ts` | SkillContext + AvailableCategories + ToolRegistry (26 tools) |
@@ -0,0 +1 @@
# fixture root
@@ -0,0 +1 @@
# fixture package
@@ -0,0 +1 @@
export const file16 = 16
@@ -0,0 +1 @@
export const file17 = 17
@@ -0,0 +1 @@
export const file18 = 18
@@ -0,0 +1 @@
export const file19 = 19
@@ -0,0 +1 @@
export const file20 = 20
@@ -0,0 +1 @@
# fixture src
@@ -0,0 +1 @@
export const file01 = 1
@@ -0,0 +1 @@
export const file02 = 2
@@ -0,0 +1 @@
export const file03 = 3
@@ -0,0 +1 @@
export const file04 = 4
@@ -0,0 +1 @@
export const file05 = 5
@@ -0,0 +1 @@
export const file06 = 6
@@ -0,0 +1 @@
export const file07 = 7
@@ -0,0 +1 @@
export const file08 = 8
@@ -0,0 +1 @@
export const file09 = 9
@@ -0,0 +1 @@
export const file10 = 10
@@ -0,0 +1 @@
export const file11 = 11
@@ -0,0 +1 @@
export const file12 = 12
@@ -0,0 +1 @@
export const file13 = 13
@@ -0,0 +1 @@
export const file14 = 14
@@ -0,0 +1 @@
export const file15 = 15
+121
View File
@@ -0,0 +1,121 @@
import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { describe, expect, it } from "bun:test"
type InitMetrics = {
coldMs: number
warmMs: [number, number]
medianMs: number
}
function getMedian(values: number[]): number {
const sorted = [...values].sort((left, right) => left - right)
return sorted[Math.floor(sorted.length / 2)] ?? 0
}
function createPluginInput(directory: string): PluginInput {
const client = createOpencodeClient({ directory })
return {
client,
project: {
id: `perf-${Date.now()}`,
worktree: directory,
time: { created: Date.now() },
},
directory,
worktree: directory,
serverUrl: new URL("http://localhost"),
$: Bun.$,
}
}
async function importFreshPluginModule(): Promise<(typeof import("../../index"))["default"]> {
const token = `${Date.now()}-${Math.random()}`
return (await import(`../../index?perf=${token}`)).default
}
async function measureInitMetrics(directory: string): Promise<InitMetrics> {
const pluginModule = await importFreshPluginModule()
const measurements: number[] = []
for (let index = 0; index < 3; index += 1) {
const input = createPluginInput(directory)
const start = performance.now()
await pluginModule.server(input, {})
measurements.push(performance.now() - start)
}
return {
coldMs: measurements[0] ?? 0,
warmMs: [measurements[1] ?? 0, measurements[2] ?? 0],
medianMs: getMedian(measurements),
}
}
async function measureScenario(
label: string,
populateDirectory: (directory: string) => void,
): Promise<InitMetrics> {
const rootDirectory = mkdtempSync(join(tmpdir(), "perf-d09-"))
const projectDirectory = join(rootDirectory, label)
const configDirectory = join(rootDirectory, "opencode-config")
const previousConfigDirectory = process.env.OPENCODE_CONFIG_DIR
mkdirSync(configDirectory, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = configDirectory
try {
populateDirectory(projectDirectory)
return await measureInitMetrics(projectDirectory)
} finally {
if (previousConfigDirectory === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = previousConfigDirectory
}
rmSync(rootDirectory, { recursive: true, force: true })
}
}
function logMetrics(label: string, metrics: InitMetrics): void {
console.info(
`${label}: cold=${metrics.coldMs.toFixed(1)}ms warm=[${metrics.warmMs.map((value) => value.toFixed(1)).join(", ")}] median=${metrics.medianMs.toFixed(1)}ms`,
)
}
describe("plugin init performance", () => {
it("stays within the empty project init budget", async () => {
// given
const metrics = await measureScenario("empty-project", (directory) => {
mkdirSync(directory, { recursive: true })
})
// when
logMetrics("empty-project", metrics)
// then
// regression budget
expect(metrics.medianMs).toBeLessThan(500)
})
it("stays within the in-tree fixture init budget", async () => {
// given
const fixtureDirectory = new URL("./fixtures/in-tree/", import.meta.url)
const metrics = await measureScenario("in-tree-fixture", (directory) => {
cpSync(fixtureDirectory, directory, { recursive: true })
})
// when
logMetrics("in-tree-fixture", metrics)
// then
// regression budget
expect(metrics.medianMs).toBeLessThan(700)
})
})
+8 -8
View File
@@ -1,6 +1,6 @@
# src/agents/ — 11 Agent Definitions
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
@@ -10,16 +10,16 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
| Agent | Model | Temp | Mode | Fallback Chain | Purpose |
|-------|-------|------|------|----------------|---------|
| **Sisyphus** | claude-opus-4-6 max | 0.1 | all | k2p5 -> kimi-k2.5 -> gpt-5.4 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
| **Sisyphus** | claude-opus-4-7 max | 0.1 | all | k2p5 -> kimi-k2.5 -> gpt-5.4 medium -> glm-5 -> big-pickle | Main orchestrator, plans + delegates |
| **Hephaestus** | gpt-5.4 medium | 0.1 | all | — | Autonomous deep worker |
| **Oracle** | gpt-5.4 high | 0.1 | subagent | gemini-3.1-pro high -> claude-opus-4-6 max | Read-only consultation |
| **Librarian** | minimax-m2.7 | 0.1 | subagent | minimax-m2.7-highspeed -> claude-haiku-4-5 -> gpt-5-nano | External docs/code search |
| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.7-highspeed -> minimax-m2.7 -> claude-haiku-4-5 -> gpt-5-nano | Contextual grep |
| **Oracle** | gpt-5.4 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 |
| **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-6 max | **0.3** | subagent | gpt-5.4 high -> gemini-3.1-pro high | Pre-planning consultant |
| **Momus** | gpt-5.4 xhigh | 0.1 | subagent | claude-opus-4-6 max -> gemini-3.1-pro high | Plan reviewer |
| **Metis** | claude-opus-4-7 max | **0.3** | subagent | gpt-5.4 high -> gemini-3.1-pro high | Pre-planning consultant |
| **Momus** | gpt-5.4 xhigh | 0.1 | subagent | claude-opus-4-7 max -> gemini-3.1-pro high | Plan reviewer |
| **Atlas** | claude-sonnet-4-6 | 0.1 | primary | gpt-5.4 medium | Todo-list orchestrator |
| **Prometheus** | claude-opus-4-6 max | 0.1 | — | internal planner | Strategic planner (internal) |
| **Prometheus** | claude-opus-4-7 max | 0.1 | — | internal planner | Strategic planner (internal) |
| **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor |
## TOOL RESTRICTIONS
+4 -4
View File
@@ -57,7 +57,7 @@ describe("Sisyphus prompt identity", () => {
describe("#given a Sisyphus agent created with default model", () => {
describe("#when checking the prompt", () => {
it("#then contains the agent identity section with override directive", () => {
const config = createSisyphusAgent("anthropic/claude-opus-4-6")
const config = createSisyphusAgent("anthropic/claude-opus-4-7")
expect(config.prompt).toContain("<agent-identity>")
expect(config.prompt).toContain("Sisyphus")
@@ -65,7 +65,7 @@ describe("Sisyphus prompt identity", () => {
})
it("#then identity section appears before the Role section", () => {
const config = createSisyphusAgent("anthropic/claude-opus-4-6")
const config = createSisyphusAgent("anthropic/claude-opus-4-7")
const prompt = config.prompt ?? ""
const identityIndex = prompt.indexOf("<agent-identity>")
const roleIndex = prompt.indexOf("<Role>")
@@ -115,7 +115,7 @@ describe("Agent identity preservation through overrides", () => {
describe("#given a Sisyphus agent with prompt_append override", () => {
describe("#when merging the override", () => {
it("#then identity section is preserved in the merged prompt", () => {
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-6")
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-7")
const merged = mergeAgentConfig(baseConfig, { prompt_append: "Extra instructions here" })
expect(merged.prompt).toContain("<agent-identity>")
@@ -129,7 +129,7 @@ describe("Agent identity preservation through overrides", () => {
describe("#given a Sisyphus agent with model override only", () => {
describe("#when merging the override", () => {
it("#then identity section is preserved unchanged", () => {
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-6")
const baseConfig = createSisyphusAgent("anthropic/claude-opus-4-7")
const merged = mergeAgentConfig(baseConfig, { model: "openai/gpt-5.4" })
expect(merged.prompt).toContain("<agent-identity>")
+6 -6
View File
@@ -150,16 +150,16 @@ task(
### 3.5 Handle Failures (USE RESUME)
**CRITICAL: When re-delegating, ALWAYS use \`session_id\` parameter.**
**CRITICAL: When re-delegating, ALWAYS use \`task_id\` parameter.**
Every \`task()\` output includes a session_id. STORE IT.
Every \`task()\` output includes a task_id. STORE IT.
If task fails:
1. Identify what went wrong
2. **Resume the SAME session** - subagent has full context already:
\`\`\`typescript
task(
session_id="ses_xyz789", // Session from failed task
task_id="ses_xyz789", // Task ID from failed task
load_skills=[...],
prompt="FAILED: {error}. Fix by: {specific instruction}"
)
@@ -167,7 +167,7 @@ If task fails:
3. Maximum 3 retry attempts with the SAME session
4. If blocked after 3 attempts: Document and continue to independent tasks
**Why session_id is MANDATORY for failures:**
**Why task_id is MANDATORY for failures:**
- Subagent already read all files, knows the context
- No repeated exploration = 70%+ token savings
- Subagent knows what approaches already failed
@@ -292,6 +292,6 @@ export const DEFAULT_ATLAS_CRITICAL_RULES = `<critical_overrides>
- Pass inherited wisdom to every subagent
- Parallelize independent tasks
- Verify with your own tools
- **Store session_id from every delegation output**
- **Use \`session_id="{session_id}"\` for retries, fixes, and follow-ups**
- **Store task_id from every delegation output**
- **Use \`task_id="{task_id}"\` for retries, fixes, and follow-ups**
</critical_overrides>`
+2 -2
View File
@@ -164,10 +164,10 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
### 3.5 Handle Failures
**CRITICAL: Use \`session_id\` for retries.**
**CRITICAL: Use \`task_id\` for retries.**
\`\`\`typescript
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
\`\`\`
- Maximum 3 retries per task
+2 -2
View File
@@ -169,10 +169,10 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
### 3.5 Handle Failures
**CRITICAL: Use \`session_id\` for retries.**
**CRITICAL: Use \`task_id\` for retries.**
\`\`\`typescript
task(session_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
\`\`\`
- Maximum 3 retries per task
@@ -43,7 +43,7 @@ describe("maybeCreateSisyphusConfig", () => {
// given
const agentOverrides: AgentOverrides = {
sisyphus: {
model: "anthropic/claude-opus-4-6",
model: "anthropic/claude-opus-4-7",
permission: {
apply_patch: "allow",
},
@@ -55,8 +55,8 @@ describe("maybeCreateSisyphusConfig", () => {
const config = maybeCreateSisyphusConfig({
disabledAgents: [],
agentOverrides,
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "anthropic/claude-opus-4-6",
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "anthropic/claude-opus-4-7",
isFirstRunNoCache: false,
availableAgents: [],
availableSkills: [],
@@ -67,7 +67,7 @@ describe("maybeCreateSisyphusConfig", () => {
// then
expect(config).toBeDefined();
expect(config?.model).toBe("anthropic/claude-opus-4-6");
expect(config?.model).toBe("anthropic/claude-opus-4-7");
// Claude models should allow the user override
expect(config?.permission).toHaveProperty("apply_patch", "allow");
});
@@ -2,13 +2,13 @@ import { describe, expect, spyOn, test } from "bun:test"
import { createBuiltinAgents } from "./builtin-agents"
import * as shared from "../shared"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-7"
describe("createBuiltinAgents custom agent visibility", () => {
test("#given runtime custom agents #when orchestrator prompts are built #then custom agents are not advertised for automatic delegation", async () => {
//#given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
try {
+1 -1
View File
@@ -182,7 +182,7 @@ Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementatio
- Single-file fix or trivial change → proceed directly
- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST
- Use \`session_id\` to resume the same Plan Agent - ask follow-up questions aggressively
- 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
Plan Agent returns a structured work breakdown with parallel execution opportunities. Follow it.`
@@ -211,7 +211,7 @@ describe("buildParallelDelegationSection", () => {
it("#given Claude model #when building #then returns empty", () => {
//#given
const model = "anthropic/claude-opus-4-6"
const model = "anthropic/claude-opus-4-7"
const categories = [deepCategory]
//#when
@@ -244,7 +244,7 @@ describe("buildNonClaudePlannerSection", () => {
//#then
expect(result).toContain("Plan Agent")
expect(result).toContain("session_id")
expect(result).toContain("task_id")
expect(result).toContain("Multi-step")
})
+4 -7
View File
@@ -25,13 +25,10 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
}
export function createExploreAgent(model: string): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"apply_patch",
"task",
"call_omo_agent",
])
const restrictions = createAgentToolRestrictions(
["write", "edit", "apply_patch", "task", "call_omo_agent"],
["lsp_symbols", "lsp_goto_definition", "lsp_find_references", "lsp_diagnostics", "ast_grep_search"],
)
return {
description:
+8 -8
View File
@@ -56,7 +56,7 @@ describe("getHephaestusPromptSource", () => {
test("returns 'gpt' for non-GPT models and undefined", () => {
// given
const model1 = "anthropic/claude-opus-4-6";
const model1 = "anthropic/claude-opus-4-7";
const model2 = undefined;
// when
@@ -124,7 +124,7 @@ describe("getHephaestusPrompt", () => {
test("Claude model returns generic GPT prompt (Hephaestus default)", () => {
// given
const model = "anthropic/claude-opus-4-6";
const model = "anthropic/claude-opus-4-7";
// when
const prompt = getHephaestusPrompt(model);
@@ -149,7 +149,7 @@ describe("getHephaestusPrompt", () => {
test("useTaskSystem=false includes Todo Discipline for Claude models", () => {
// given
const model = "anthropic/claude-opus-4-6";
const model = "anthropic/claude-opus-4-7";
// when
const prompt = getHephaestusPrompt(model, false);
@@ -239,7 +239,7 @@ describe("createHephaestusAgent", () => {
// given
const gpt54Model = "openai/gpt-5.4";
const gptGenericModel = "openai/gpt-4o";
const claudeModel = "anthropic/claude-opus-4-6";
const claudeModel = "anthropic/claude-opus-4-7";
// when
const gpt54Config = createHephaestusAgent(gpt54Model);
@@ -322,7 +322,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => {
// given
const agentOverrides: AgentOverrides = {
hephaestus: {
model: "anthropic/claude-opus-4-6",
model: "anthropic/claude-opus-4-7",
permission: {
apply_patch: "allow",
},
@@ -334,8 +334,8 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => {
const config = maybeCreateHephaestusConfig({
disabledAgents: [],
agentOverrides,
availableModels: new Set(["anthropic/claude-opus-4-6"]),
systemDefaultModel: "anthropic/claude-opus-4-6",
availableModels: new Set(["anthropic/claude-opus-4-7"]),
systemDefaultModel: "anthropic/claude-opus-4-7",
isFirstRunNoCache: false,
availableAgents: [],
availableSkills: [],
@@ -346,7 +346,7 @@ describe("maybeCreateHephaestusConfig GPT apply_patch guard", () => {
// then
expect(config).toBeDefined();
expect(config?.model).toBe("anthropic/claude-opus-4-6");
expect(config?.model).toBe("anthropic/claude-opus-4-7");
expect(config?.permission).toHaveProperty("apply_patch", "allow");
});
});
+3 -3
View File
@@ -409,9 +409,9 @@ After delegation, ALWAYS verify: works as expected? follows codebase pattern? MU
Every \`task()\` output includes a session_id. **USE IT for follow-ups.**
- **Task failed/incomplete** - \`session_id="{id}", prompt="Fix: {error}"\`
- **Follow-up on result** - \`session_id="{id}", prompt="Also: {question}"\`
- **Verification failed** - \`session_id="{id}", prompt="Failed: {error}. Fix."\`
- **Task failed/incomplete** - \`task_id="{id}", prompt="Fix: {error}"\`
- **Follow-up on result** - \`task_id="{id}", prompt="Also: {question}"\`
- **Verification failed** - \`task_id="{id}", prompt="Failed: {error}. Fix."\`
${
oracleSection
+4 -4
View File
@@ -312,10 +312,10 @@ Every delegation prompt needs these 6 sections:
After delegation, verify by reading every file the subagent touched. Check: works as expected? follows codebase pattern? Do not trust self-reports.
<session_continuity>
Every \`task()\` returns a session_id. Use it for all follow-ups:
- Task failed/incomplete: \`session_id="{id}", prompt="Fix: {error}"\`
- Follow-up on result: \`session_id="{id}", prompt="Also: {question}"\`
- Verification failed: \`session_id="{id}", prompt="Failed: {error}. Fix."\`
Every \`task()\` returns a task_id. Use it for all follow-ups:
- Task failed/incomplete: \`task_id="{id}", prompt="Fix: {error}"\`
- Follow-up on result: \`task_id="{id}", prompt="Also: {question}"\`
- Verification failed: \`task_id="{id}", prompt="Failed: {error}. Fix."\`
This preserves full context, avoids repeated exploration, saves 70%+ tokens.
</session_continuity>
+4 -4
View File
@@ -277,11 +277,11 @@ After delegation, ALWAYS verify: works as expected? follows codebase pattern? MU
### Session Continuity
Every \`task()\` output includes a session_id. **USE IT for follow-ups.**
Every \`task()\` output includes a task_id. **USE IT for follow-ups.**
- **Task failed/incomplete** - \`session_id="{id}", prompt="Fix: {error}"\`
- **Follow-up on result** - \`session_id="{id}", prompt="Also: {question}"\`
- **Verification failed** - \`session_id="{id}", prompt="Failed: {error}. Fix."\`
- **Task failed/incomplete** - \`task_id="{id}", prompt="Fix: {error}"\`
- **Follow-up on result** - \`task_id="{id}", prompt="Also: {question}"\`
- **Verification failed** - \`task_id="{id}", prompt="Failed: {error}. Fix."\`
${
oracleSection
+8 -8
View File
@@ -317,15 +317,15 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
### Session Continuity (MANDATORY)
Every \`task()\` output includes a session_id. **USE IT.**
Every \`task()\` output includes a task_id. **USE IT.**
**ALWAYS continue when:**
- Task failed/incomplete → \`session_id=\"{session_id}\", prompt=\"Fix: {specific error}\"\`
- Follow-up question on result → \`session_id=\"{session_id}\", prompt=\"Also: {question}\"\`
- Multi-turn with same agent → \`session_id=\"{session_id}\"\` - NEVER start fresh
- Verification failed → \`session_id=\"{session_id}\", prompt=\"Failed verification: {error}. Fix.\"\`
- Task failed/incomplete → \`task_id=\"{task_id}\", prompt=\"Fix: {specific error}\"\`
- Follow-up question on result → \`task_id=\"{task_id}\", prompt=\"Also: {question}\"\`
- Multi-turn with same agent → \`task_id=\"{task_id}\"\` - NEVER start fresh
- Verification failed → \`task_id=\"{task_id}\", prompt=\"Failed verification: {error}. Fix.\"\`
**Why session_id is CRITICAL:**
**Why task_id is CRITICAL:**
- Subagent has FULL conversation context preserved
- No repeated file reads, exploration, or setup
- Saves 70%+ tokens on follow-ups
@@ -336,10 +336,10 @@ Every \`task()\` output includes a session_id. **USE IT.**
task(category="quick", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix the type error in auth.ts...")
// CORRECT: Resume preserves everything
task(session_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
task(task_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
\`\`\`
**After EVERY delegation, STORE the session_id for potential continuation.**
**After EVERY delegation, STORE the task_id for potential continuation.**
### Code Changes:
- Match existing patterns (if codebase is disciplined)
+8 -8
View File
@@ -389,15 +389,15 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
### Session Continuity (MANDATORY)
Every \`task()\` output includes a session_id. **USE IT.**
Every \`task()\` output includes a task_id. **USE IT.**
**ALWAYS continue when:**
- Task failed/incomplete → \`session_id="{session_id}", prompt="Fix: {specific error}"\`
- Follow-up question on result → \`session_id="{session_id}", prompt="Also: {question}"\`
- Multi-turn with same agent → \`session_id="{session_id}"\` - NEVER start fresh
- Verification failed → \`session_id="{session_id}", prompt="Failed verification: {error}. Fix."\`
- Task failed/incomplete → \`task_id="{task_id}", prompt="Fix: {specific error}"\`
- Follow-up question on result → \`task_id="{task_id}", prompt="Also: {question}"\`
- Multi-turn with same agent → \`task_id="{task_id}"\` - NEVER start fresh
- Verification failed → \`task_id="{task_id}", prompt="Failed verification: {error}. Fix."\`
**Why session_id is CRITICAL:**
**Why task_id is CRITICAL:**
- Subagent has FULL conversation context preserved
- No repeated file reads, exploration, or setup
- Saves 70%+ tokens on follow-ups
@@ -408,10 +408,10 @@ Every \`task()\` output includes a session_id. **USE IT.**
task(category="quick", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix the type error in auth.ts...")
// CORRECT: Resume preserves everything
task(session_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
task(task_id="ses_abc123", load_skills=[], run_in_background=false, description="Fix type error", prompt="Fix: Type error on line 42")
\`\`\`
**After EVERY delegation, STORE the session_id for potential continuation.**
**After EVERY delegation, STORE the task_id for potential continuation.**
### Code Changes:
- Match existing patterns (if codebase is disciplined)
+4 -4
View File
@@ -387,10 +387,10 @@ Post-delegation: delegation never substitutes for verification. Always run \`<ve
### Session continuity
Every \`task()\` returns a session_id. Use it for all follow-ups:
- Failed/incomplete → \`session_id="{id}", prompt="Fix: {specific error}"\`
- Follow-up → \`session_id="{id}", prompt="Also: {question}"\`
- Multi-turn → always \`session_id\`, never start fresh
Every \`task()\` returns a task_id. Use it for all follow-ups:
- Failed/incomplete → \`task_id="{id}", prompt="Fix: {specific error}"\`
- Follow-up → \`task_id="{id}", prompt="Also: {question}"\`
- Multi-turn → always \`task_id\`, never start fresh
This preserves full context, avoids repeated exploration, saves 70%+ tokens.
+7 -7
View File
@@ -18,7 +18,7 @@ describe("isGpt5_4Model", () => {
});
test("does not match non-GPT models", () => {
expect(isGpt5_4Model("anthropic/claude-opus-4-6")).toBe(false);
expect(isGpt5_4Model("anthropic/claude-opus-4-7")).toBe(false);
expect(isGpt5_4Model("google/gemini-3.1-pro")).toBe(false);
expect(isGpt5_4Model("openai/o1")).toBe(false);
});
@@ -64,7 +64,7 @@ describe("isGptModel", () => {
});
test("claude models are not gpt", () => {
expect(isGptModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGptModel("anthropic/claude-opus-4-7")).toBe(false);
expect(isGptModel("anthropic/claude-sonnet-4-6")).toBe(false);
expect(isGptModel("litellm/anthropic.claude-opus-4-5")).toBe(false);
});
@@ -75,7 +75,7 @@ describe("isGptModel", () => {
});
test("opencode provider is not gpt", () => {
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
expect(isGptModel("opencode/claude-opus-4-7")).toBe(false);
});
});
@@ -95,7 +95,7 @@ describe("isMiniMaxModel", () => {
test("does not match non-minimax models", () => {
expect(isMiniMaxModel("openai/gpt-5.4")).toBe(false);
expect(isMiniMaxModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isMiniMaxModel("anthropic/claude-opus-4-7")).toBe(false);
expect(isMiniMaxModel("google/gemini-3.1-pro")).toBe(false);
expect(isMiniMaxModel("opencode-go/kimi-k2.5")).toBe(false);
});
@@ -116,7 +116,7 @@ describe("isGlmModel", () => {
test("#given non-GLM models #then returns false", () => {
expect(isGlmModel("openai/gpt-5.4")).toBe(false);
expect(isGlmModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGlmModel("anthropic/claude-opus-4-7")).toBe(false);
expect(isGlmModel("google/gemini-3.1-pro")).toBe(false);
});
});
@@ -156,11 +156,11 @@ describe("isGeminiModel", () => {
});
test("#given claude models #then returns false", () => {
expect(isGeminiModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGeminiModel("anthropic/claude-opus-4-7")).toBe(false);
expect(isGeminiModel("anthropic/claude-sonnet-4-6")).toBe(false);
});
test("#given opencode provider #then returns false", () => {
expect(isGeminiModel("opencode/claude-opus-4-6")).toBe(false);
expect(isGeminiModel("opencode/claude-opus-4-7")).toBe(false);
});
});
+23 -23
View File
@@ -7,7 +7,7 @@ import * as connectedProvidersCache from "../shared/connected-providers-cache"
import * as modelAvailability from "../shared/model-availability"
import * as shared from "../shared"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-7"
let createBuiltinAgents: (typeof import("./builtin-agents"))["createBuiltinAgents"]
async function importFreshBuiltinAgentsModule(): Promise<typeof import("./builtin-agents")> {
@@ -32,7 +32,7 @@ describe("createBuiltinAgents with model overrides", () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
"kimi-for-coding/k2p5",
"opencode/kimi-k2.5-free",
"zai-coding-plan/glm-5",
@@ -45,7 +45,7 @@ describe("createBuiltinAgents with model overrides", () => {
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
} finally {
@@ -170,7 +170,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("Sisyphus is created on first run when no availableModels or cache exist", async () => {
// #given
const systemDefaultModel = "anthropic/claude-opus-4-6"
const systemDefaultModel = "anthropic/claude-opus-4-7"
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
@@ -180,7 +180,7 @@ describe("createBuiltinAgents with model overrides", () => {
// #then
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.7")
} finally {
cacheSpy.mockRestore()
fetchSpy.mockRestore()
@@ -299,7 +299,7 @@ describe("createBuiltinAgents with model overrides", () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
"kimi-for-coding/k2p5",
"opencode/kimi-k2.5-free",
"zai-coding-plan/glm-5",
@@ -341,7 +341,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-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
const customAgentSummaries = [
@@ -377,7 +377,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-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
const customAgentSummaries = [
@@ -413,7 +413,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-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
const disabledAgents = ["ReSeArChEr"]
@@ -449,7 +449,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-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
const customAgentSummaries = [
@@ -481,7 +481,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-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
const customAgentSummaries = [
@@ -555,7 +555,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
])
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-7",
"kimi-for-coding/k2p5",
"opencode/kimi-k2.5-free",
"zai-coding-plan/glm-5",
@@ -569,7 +569,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
// #then
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
} finally {
cacheSpy.mockRestore()
fetchSpy.mockRestore()
@@ -590,7 +590,7 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
const providers = options?.connectedProviders ?? []
return providers.includes("openai")
? new Set(["openai/gpt-5.3-codex"])
: new Set(["anthropic/claude-opus-4-6"])
: new Set(["anthropic/claude-opus-4-7"])
})
try {
@@ -609,7 +609,7 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
test("hephaestus is not created when no required provider is connected", async () => {
// #given - only anthropic models available, not in hephaestus requiresProvider
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-6"])
new Set(["anthropic/claude-opus-4-7"])
)
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
@@ -699,10 +699,10 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () =>
test("hephaestus is created when explicit config provided even if provider unavailable", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-6"])
new Set(["anthropic/claude-opus-4-7"])
)
const overrides = {
hephaestus: { model: "anthropic/claude-opus-4-6" },
hephaestus: { model: "anthropic/claude-opus-4-7" },
}
try {
@@ -781,7 +781,7 @@ describe("Sisyphus and Librarian environment context toggle", () => {
beforeEach(() => {
fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-6", "google/gemini-3-flash"])
new Set(["anthropic/claude-opus-4-7", "google/gemini-3-flash"])
)
})
@@ -840,7 +840,7 @@ describe("Atlas is unaffected by environment context toggle", () => {
beforeEach(() => {
fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-6", "openai/gpt-5.4"])
new Set(["anthropic/claude-opus-4-7", "openai/gpt-5.4"])
)
})
@@ -893,7 +893,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
test("sisyphus is created when at least one fallback model is available", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-6"])
new Set(["anthropic/claude-opus-4-7"])
)
try {
@@ -918,7 +918,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
// #then
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.7")
} finally {
cacheSpy.mockRestore()
fetchSpy.mockRestore()
@@ -929,7 +929,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
const overrides = {
sisyphus: { model: "anthropic/claude-opus-4-6" },
sisyphus: { model: "anthropic/claude-opus-4-7" },
}
try {
@@ -1039,7 +1039,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
describe("buildAgent with category and skills", () => {
const { buildAgent } = require("./agent-builder")
const TEST_MODEL = "anthropic/claude-opus-4-6"
const TEST_MODEL = "anthropic/claude-opus-4-7"
beforeEach(() => {
clearSkillCache()
+1 -1
View File
@@ -1,6 +1,6 @@
# src/cli/ — CLI: install, run, doctor, mcp-oauth
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -46,7 +46,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi > Verce
OpenAI Native openai/ models (GPT-5.4 for Oracle)
Gemini Native google/ models (Gemini 3.1 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
OpenCode Zen opencode/ models (opencode/claude-opus-4-7, etc.)
Z.ai zai-coding-plan/glm-5 (visual-engineering fallback)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
Vercel vercel/ models (universal proxy, always last fallback)
@@ -26,8 +26,8 @@ describe("generateOmoConfig - model fallback system", () => {
//#then
expect([
"github-copilot/claude-opus-4.6",
"github-copilot/claude-opus-4-6",
"github-copilot/claude-opus-4.7",
"github-copilot/claude-opus-4-7",
]).toContain((result.agents as Record<string, { model: string }>).sisyphus.model)
})
@@ -74,7 +74,7 @@ describe("generateOmoConfig - model fallback system", () => {
//#then
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-7")
})
test("uses native OpenAI models when only ChatGPT available", () => {
@@ -131,7 +131,7 @@ describe("generateOmoConfig - model fallback system", () => {
}>
//#then
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-7")
expect(agents.sisyphus.fallback_models).toEqual([
{
model: "openai/gpt-5.4",
@@ -141,7 +141,7 @@ describe("generateOmoConfig - model fallback system", () => {
expect(categories.deep.model).toBe("openai/gpt-5.4")
expect(categories.deep.fallback_models).toEqual([
{
model: "anthropic/claude-opus-4.6",
model: "anthropic/claude-opus-4-7",
variant: "max",
},
])
+82
View File
@@ -0,0 +1,82 @@
# src/cli/doctor/ — Health Diagnostics (25 Check Files)
**Generated:** 2026-04-18
## OVERVIEW
`bunx oh-my-opencode doctor` — parallel diagnostic checks across 4 categories (System, Config, Tools, Models). Catches broken installs, config typos, missing dependencies, provider misconfigurations before they become runtime errors.
## COMMAND FLAGS
```bash
bunx oh-my-opencode doctor # Full diagnostics (all 4 categories)
bunx oh-my-opencode doctor --status # Compact dashboard (status only)
bunx oh-my-opencode doctor --verbose # Deep details (model resolution traces)
bunx oh-my-opencode doctor --json # Machine-readable output
```
## CHECK CATEGORIES
| Category | File | Validates |
|----------|------|-----------|
| **SYSTEM** | `checks/system.ts` | OpenCode binary found + version ≥1.0.150, plugin registered in opencode.json, loaded plugin version matches installed |
| **CONFIG** | `checks/config.ts` | JSONC validity, Zod schema passes, no unknown keys, model override syntax correct |
| **TOOLS** | `checks/tools.ts` | AST-Grep CLI + NAPI, comment-checker binary, LSP servers reachable, GitHub CLI auth, built-in MCP reachability |
| **MODELS** | `checks/model-resolution.ts` | models.json cache exists, per-agent fallback resolution, category overrides valid, provider availability |
## SUPPORTING CHECK FILES (25 total)
```
checks/
├── index.ts # Registration
├── system.ts # Main System aggregator
├── system-binary.ts # OpenCode binary discovery (PATH + desktop app)
├── system-plugin.ts # opencode.json plugin entry detection
├── system-loaded-version.ts # Cache vs npm latest
├── config.ts # Main Config aggregator
├── tools.ts # Main Tools aggregator
├── dependencies.ts # AST-Grep CLI/NAPI + comment-checker presence
├── tools-gh.ts # gh cli install + auth status
├── tools-lsp.ts # LSP server enumeration
├── tools-mcp.ts # Built-in + user MCP reachability
├── model-resolution.ts # Main Models aggregator
├── model-resolution-cache.ts # models.json presence + freshness
├── model-resolution-config.ts # oh-my-opencode.jsonc parse
├── model-resolution-effective-model.ts # Per-agent fallback chain trace
├── model-resolution-variant.ts # Model variant (max, high, medium) handling
├── model-resolution-details.ts # Verbose output formatter
└── model-resolution-types.ts # Shared types
```
## EXECUTION FLOW
```
doctor command
→ runner.ts: parallel check execution with 30s per-check timeout
→ checks/index.ts registers all 4 category checks
→ each check returns: { status: "ok" | "warn" | "error", detail: string }
→ formatter.ts: render to stdout (text/status/json)
→ exit code: 0 (all ok) | 1 (errors) | 2 (warnings only)
```
## KEY FILES
| File | Purpose |
|------|---------|
| `index.ts` | CLI command entry, flag parsing |
| `runner.ts` | Parallel `Promise.allSettled()` orchestration, 30s timeout per check |
| `formatter.ts` | Pretty printing: colored status, hierarchical output |
| `types.ts` | `DoctorCheck`, `CheckResult`, `DoctorReport` types |
## HOW TO ADD A CHECK
1. Create `src/cli/doctor/checks/{name}.ts` exporting check function matching `DoctorCheck`
2. Register in `checks/index.ts`
3. Category-level aggregator (system/config/tools/model-resolution) invokes it
4. Return `{ status, detail }` — no throws, all errors caught by runner
## EXIT CODES
- `0`: All checks passed (or only info messages)
- `1`: One or more errors — plugin will likely not work
- `2`: Warnings only — plugin works with degraded features
@@ -34,7 +34,7 @@ describe("loadAvailableModelsFromCache", () => {
join(tempDir, "cache", "opencode", "models.json"),
JSON.stringify({
openai: { models: { "gpt-5.4": {} } },
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } },
anthropic: { models: { "claude-opus-4-7": {}, "claude-sonnet-4-6": {} } },
})
)
@@ -14,7 +14,7 @@ describe("model-resolution check", () => {
// then: Should have agent entries
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6")
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-7")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
})
@@ -42,7 +42,7 @@ describe("model-resolution check", () => {
// given: User has override for oracle agent
const mockConfig = {
agents: {
oracle: { model: "anthropic/claude-opus-4-6" },
oracle: { model: "anthropic/claude-opus-4-7" },
},
}
@@ -51,8 +51,8 @@ describe("model-resolution check", () => {
// then: Oracle should show the override
const oracle = info.agents.find((a) => a.name === "oracle")
expect(oracle).toBeDefined()
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-6")
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-6")
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-7")
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-7")
})
it("shows user override for category when configured", async () => {
@@ -169,13 +169,13 @@ describe("model-resolution check", () => {
const info = getModelResolutionInfoWithOverrides({
agents: {
oracle: { model: "anthropic/claude-opus-4-6-thinking" },
oracle: { model: "anthropic/claude-opus-4-7-thinking" },
},
})
const oracle = info.agents.find((agent) => agent.name === "oracle")
expect(oracle).toBeDefined()
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-6-thinking")
expect(oracle!.effectiveModel).toBe("anthropic/claude-opus-4-7-thinking")
expect(oracle!.capabilityDiagnostics).toMatchObject({
resolutionMode: "alias-backed",
canonicalization: {
+20 -24
View File
@@ -355,9 +355,9 @@ describe("generateModelConfig", () => {
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use native OpenAI model
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4")
expect(result.agents?.explore?.variant).toBe("medium")
// #then explore should use native OpenAI mini-fast (primary model)
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4-mini-fast")
expect(result.agents?.explore?.variant).toBeUndefined()
})
test("explore uses gpt-5-mini when only Copilot available", () => {
@@ -381,7 +381,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
})
test("Sisyphus is created when multiple fallback providers are available", () => {
@@ -398,7 +398,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
})
test("Sisyphus resolves to gpt-5.4 medium when only OpenAI is available", () => {
@@ -553,15 +553,15 @@ describe("generateModelConfig", () => {
})
describe("special-case agents include fallback_models", () => {
test("explore includes fallback_models when Copilot and Claude are both available", () => {
// #given both Copilot and Claude are available
const config = createConfig({ hasCopilot: true, hasClaude: true })
test("explore includes fallback_models when OpenAI and Claude are both available", () => {
// #given both OpenAI and Claude are available
const config = createConfig({ hasOpenAI: true, hasClaude: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should have fallback_models from the remaining chain entries
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4-mini-fast")
expect(result.agents?.explore?.fallback_models).toBeDefined()
expect(result.agents?.explore?.fallback_models?.length).toBeGreaterThan(0)
})
@@ -573,37 +573,33 @@ describe("generateModelConfig", () => {
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should not have fallback_models (only one chain entry matches)
// #then explore should not have fallback_models (only one distinct chain entry matches)
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
expect(result.agents?.explore?.fallback_models).toEqual([
{
model: "anthropic/claude-haiku-4.5",
},
])
expect(result.agents?.explore?.fallback_models).toBeUndefined()
})
test("librarian includes fallback_models when opencode-go and Claude are both available", () => {
// #given opencode-go and Claude are available
const config = createConfig({ hasOpencodeGo: true, hasClaude: true })
test("librarian includes fallback_models when OpenAI and opencode-go are both available", () => {
// #given OpenAI and opencode-go are available
const config = createConfig({ hasOpenAI: true, hasOpencodeGo: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should have fallback_models
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
expect(result.agents?.librarian?.model).toBe("openai/gpt-5.4-mini-fast")
expect(result.agents?.librarian?.fallback_models).toBeDefined()
expect(result.agents?.librarian?.fallback_models?.length).toBeGreaterThan(0)
})
test("librarian omits fallback_models when only one provider matches", () => {
// #given only opencode-go is available
const config = createConfig({ hasOpencodeGo: true })
test("librarian omits fallback_models when only ZAI is available", () => {
// #given only ZAI is available
const config = createConfig({ hasZaiCodingPlan: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should not have fallback_models
expect(result.agents?.librarian?.model).toBe("opencode-go/minimax-m2.7")
expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7")
expect(result.agents?.librarian?.fallback_models).toBeUndefined()
})
})
@@ -672,7 +668,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then should prefer native anthropic over gateway
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-7")
})
})
+6 -2
View File
@@ -127,7 +127,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
if (role === "librarian") {
let agentConfig: AgentConfig | undefined
if (avail.opencodeGo) {
if (avail.native.openai) {
agentConfig = { model: "openai/gpt-5.4-mini-fast" }
} else if (avail.opencodeGo) {
agentConfig = { model: "opencode-go/minimax-m2.7" }
} else if (avail.zai) {
agentConfig = { model: ZAI_MODEL }
@@ -142,7 +144,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
if (role === "explore") {
let agentConfig: AgentConfig
if (avail.native.claude) {
if (avail.native.openai) {
agentConfig = { model: "openai/gpt-5.4-mini-fast" }
} else if (avail.native.claude) {
agentConfig = { model: "anthropic/claude-haiku-4-5" }
} else if (avail.opencodeZen) {
agentConfig = { model: "opencode/claude-haiku-4-5" }
+6 -4
View File
@@ -28,8 +28,8 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.explore).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
expect(result.agents?.librarian).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
expect(result.agents?.explore).toEqual({ model: "openai/gpt-5.4-mini-fast" })
expect(result.agents?.librarian).toEqual({ model: "openai/gpt-5.4-mini-fast" })
})
test("fills remaining OpenAI-only category gaps with OpenAI models", () => {
@@ -54,8 +54,10 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.explore).toMatchObject({ model: "opencode-go/minimax-m2.7" })
expect(result.agents?.librarian).toMatchObject({ model: "opencode-go/minimax-m2.7" })
expect(result.agents?.explore).toMatchObject({ model: "openai/gpt-5.4-mini-fast" })
expect(result.agents?.librarian).toMatchObject({ model: "openai/gpt-5.4-mini-fast" })
expect(result.agents?.explore).not.toMatchObject({ variant: "medium" })
expect(result.agents?.librarian).not.toMatchObject({ variant: "medium" })
expect(result.categories?.quick).toMatchObject({ model: "openai/gpt-5.4-mini" })
})
})
+2 -2
View File
@@ -1,8 +1,8 @@
import type { AgentConfig, CategoryConfig, GeneratedOmoConfig, ProviderAvailability } from "./model-fallback-types"
const OPENAI_ONLY_AGENT_OVERRIDES: Record<string, AgentConfig> = {
explore: { model: "openai/gpt-5.4", variant: "medium" },
librarian: { model: "openai/gpt-5.4", variant: "medium" },
explore: { model: "openai/gpt-5.4-mini-fast" },
librarian: { model: "openai/gpt-5.4-mini-fast" },
}
const OPENAI_ONLY_CATEGORY_OVERRIDES: Record<string, CategoryConfig> = {
+35 -33
View File
@@ -5,16 +5,16 @@ import { transformModelForProvider as transformSharedModelForProvider } from "..
describe("transformModelForProvider", () => {
describe("github-copilot provider", () => {
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
// #given github-copilot provider and claude-opus-4-6 model
test("transforms claude-opus-4-7 to claude-opus-4.7", () => {
// #given github-copilot provider and claude-opus-4-7 model
const provider = "github-copilot"
const model = "claude-opus-4-6"
const model = "claude-opus-4-7"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-opus-4.6
expect(result).toBe("claude-opus-4.6")
// #then should transform to claude-opus-4.7
expect(result).toBe("claude-opus-4.7")
})
test("transforms claude-sonnet-4-5 to claude-sonnet-4.5", () => {
@@ -152,32 +152,32 @@ describe("transformModelForProvider", () => {
})
test("does not transform claude models for google provider", () => {
// #given google provider and claude-opus-4-6 model
// #given google provider and claude-opus-4-7 model
const provider = "google"
const model = "claude-opus-4-6"
const model = "claude-opus-4-7"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should pass through unchanged (google doesn't use claude)
expect(result).toBe("claude-opus-4-6")
expect(result).toBe("claude-opus-4-7")
})
})
describe("anthropic provider", () => {
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
// #given anthropic provider and claude-opus-4-6 model
test("preserves hyphenated claude-opus-4-7 for config output (regression: installer must not write dotted IDs)", () => {
// #given anthropic provider and claude-opus-4-7 model
const provider = "anthropic"
const model = "claude-opus-4-6"
const model = "claude-opus-4-7"
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-opus-4.6
expect(result).toBe("claude-opus-4.6")
// #then should keep hyphenated form so Anthropic provider resolution succeeds on fresh installs
expect(result).toBe("claude-opus-4-7")
})
test("transforms claude-sonnet-4-6 to claude-sonnet-4.6", () => {
test("preserves hyphenated claude-sonnet-4-6 for config output", () => {
// #given anthropic provider and claude-sonnet-4-6 model
const provider = "anthropic"
const model = "claude-sonnet-4-6"
@@ -185,11 +185,11 @@ describe("transformModelForProvider", () => {
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-sonnet-4.6
expect(result).toBe("claude-sonnet-4.6")
// #then should keep hyphenated form
expect(result).toBe("claude-sonnet-4-6")
})
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
test("preserves hyphenated claude-haiku-4-5 for config output", () => {
// #given anthropic provider and claude-haiku-4-5 model
const provider = "anthropic"
const model = "claude-haiku-4-5"
@@ -197,19 +197,19 @@ describe("transformModelForProvider", () => {
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-haiku-4.5
expect(result).toBe("claude-haiku-4.5")
// #then should keep hyphenated form
expect(result).toBe("claude-haiku-4-5")
})
})
describe("vercel provider", () => {
test("prepends anthropic/ and applies anthropic transform for claude models", () => {
// #given vercel provider and claude-opus-4-6 model
// #given vercel provider and claude-opus-4-7 model
// #when transformModelForProvider is called
const result = transformModelForProvider("vercel", "claude-opus-4-6")
const result = transformModelForProvider("vercel", "claude-opus-4-7")
// #then should produce anthropic/claude-opus-4.6
expect(result).toBe("anthropic/claude-opus-4.6")
// #then should produce anthropic/claude-opus-4.7
expect(result).toBe("anthropic/claude-opus-4.7")
})
test("prepends anthropic/ and applies anthropic transform for claude-sonnet", () => {
@@ -267,12 +267,12 @@ describe("transformModelForProvider", () => {
})
test("delegates to sub-provider when model already has sub-provider prefix", () => {
// #given vercel provider and anthropic/claude-opus-4-6 (already prefixed)
// #given vercel provider and anthropic/claude-opus-4-7 (already prefixed)
// #when transformModelForProvider is called
const result = transformModelForProvider("vercel", "anthropic/claude-opus-4-6")
const result = transformModelForProvider("vercel", "anthropic/claude-opus-4-7")
// #then should apply anthropic transform within the prefix
expect(result).toBe("anthropic/claude-opus-4.6")
expect(result).toBe("anthropic/claude-opus-4.7")
})
test("prepends minimax/ for minimax models", () => {
@@ -338,14 +338,16 @@ describe("transformModelForProvider", () => {
})
})
test("uses a CLI-local transform implementation", () => {
// #given
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-6")
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-6")
test("uses a CLI-local transform implementation distinct from the shared runtime transform", () => {
// #given the CLI transform (used by the installer) and the shared runtime transform
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-7")
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-7")
// #when
// #when both are called with the same anthropic claude input
// #then the CLI preserves hyphenated form for config output,
// the shared runtime transform converts dash→dot for API calls
expect(transformModelForProvider).not.toBe(transformSharedModelForProvider)
expect(cliResult).toBe("claude-opus-4.6")
expect(sharedResult).toBe("claude-opus-4.6")
expect(cliResult).toBe("claude-opus-4-7")
expect(sharedResult).toBe("claude-opus-4.7")
})
})
+6 -1
View File
@@ -54,7 +54,12 @@ export function transformModelForProvider(provider: string, model: string): stri
}
if (provider === "anthropic") {
return claudeVersionDot(model)
// Installer writes hyphenated IDs (claude-opus-4-7) to the config. The
// runtime provider-model-id-transform converts dash→dot when calling the
// Anthropic API. Keeping the dotted form in the config breaks fresh
// installs with ProviderModelNotFoundError because Anthropic's provider
// registers models under hyphenated IDs.
return model
}
return model
+1 -1
View File
@@ -49,7 +49,7 @@ describe("refreshModelCapabilities", () => {
sourceUrl: "https://override.example/api.json",
models: {
"gpt-5.4": { id: "gpt-5.4" },
"claude-opus-4-6": { id: "claude-opus-4-6" },
"claude-opus-4-7": { id: "claude-opus-4-7" },
},
}))
let stdout = ""
+11 -11
View File
@@ -98,7 +98,7 @@ describe("message.part.delta handling", () => {
sessionID: "ses_main",
role: "assistant",
agent: "Sisyphus - Ultraworker",
modelID: "claude-opus-4-6",
modelID: "claude-opus-4-7",
variant: "max",
},
},
@@ -113,7 +113,7 @@ describe("message.part.delta handling", () => {
//#then
const rendered = stdoutSpy.mock.calls.map((call) => String(call[0] ?? "")).join("")
expect(rendered).toContain("\u001b[38;2;0;206;209m")
expect(rendered).toContain("claude-opus-4-6 (max)")
expect(rendered).toContain("claude-opus-4-7 (max)")
expect(rendered).toContain("└─")
expect(rendered).toContain("Sisyphus - Ultraworker")
stdoutSpy.mockRestore()
@@ -128,7 +128,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
},
},
{
@@ -187,7 +187,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
info: { sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
},
},
{
@@ -242,7 +242,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
},
},
{
@@ -309,7 +309,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
},
},
{
@@ -353,7 +353,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6", variant: "max" },
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7", variant: "max" },
},
},
{
@@ -388,7 +388,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
info: { id: "msg_user", sessionID: "ses_main", role: "user", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
},
},
{
@@ -410,7 +410,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-6" },
info: { id: "msg_assistant", sessionID: "ses_main", role: "assistant", agent: "Sisyphus - Ultraworker", modelID: "claude-opus-4-7" },
},
},
{
@@ -619,7 +619,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-7" },
},
},
{
@@ -634,7 +634,7 @@ describe("message.part.delta handling", () => {
{
type: "message.updated",
properties: {
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-6" },
info: { id: "msg_1", sessionID: "ses_main", role: "assistant", agent: "Sisyphus", modelID: "claude-opus-4-7" },
},
},
{
+55 -3
View File
@@ -3,6 +3,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun
import * as originalSdk from "@opencode-ai/sdk"
import * as originalPortUtils from "../../shared/port-utils"
import * as originalBinaryResolver from "./opencode-binary-resolver"
import * as originalServerAuth from "../../shared/opencode-server-auth"
const originalConsole = globalThis.console
@@ -13,11 +14,15 @@ const mockCreateOpencode = mock(() =>
server: { url: "http://127.0.0.1:4096", close: mockServerClose },
})
)
const mockCreateOpencodeClient = mock(() => ({ session: {} }))
const mockCreateOpencodeClient = mock((options?: { baseUrl?: string }) => ({
session: {},
baseUrl: options?.baseUrl,
}))
const mockIsPortAvailable = mock(() => Promise.resolve(true))
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
const mockConsoleLog = mock(() => {})
const mockWithWorkingOpencodePath = mock((startServer: () => Promise<unknown>) => startServer())
const mockInjectServerAuthIntoClient = mock(() => {})
mock.module("@opencode-ai/sdk", () => ({
createOpencode: mockCreateOpencode,
@@ -34,10 +39,15 @@ mock.module("./opencode-binary-resolver", () => ({
withWorkingOpencodePath: mockWithWorkingOpencodePath,
}))
mock.module("../../shared/opencode-server-auth", () => ({
injectServerAuthIntoClient: mockInjectServerAuthIntoClient,
}))
afterAll(() => {
mock.module("@opencode-ai/sdk", () => originalSdk)
mock.module("../../shared/port-utils", () => originalPortUtils)
mock.module("./opencode-binary-resolver", () => originalBinaryResolver)
mock.module("../../shared/opencode-server-auth", () => originalServerAuth)
mock.restore()
})
@@ -52,6 +62,7 @@ describe("createServerConnection", () => {
mockServerClose.mockClear()
mockConsoleLog.mockClear()
mockWithWorkingOpencodePath.mockClear()
mockInjectServerAuthIntoClient.mockClear()
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
})
@@ -59,6 +70,49 @@ describe("createServerConnection", () => {
globalThis.console = originalConsole
})
it("attach mode injects auth only for loopback URLs", async () => {
// given
const signal = new AbortController().signal
// when
const localhostResult = await createServerConnection({ attach: "http://localhost:8080", signal })
const loopbackResult = await createServerConnection({ attach: "http://127.0.0.1:8080", signal })
const anyBindResult = await createServerConnection({ attach: "http://0.0.0.0:8080", signal })
const remoteResult = await createServerConnection({ attach: "https://example.com", signal })
// then
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://localhost:8080" })
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://0.0.0.0:8080" })
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "https://example.com" })
expect(mockInjectServerAuthIntoClient).toHaveBeenCalledTimes(3)
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(1, localhostResult.client)
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(2, loopbackResult.client)
expect(mockInjectServerAuthIntoClient).toHaveBeenNthCalledWith(3, anyBindResult.client)
expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalledWith(remoteResult.client)
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
localhostResult.cleanup()
loopbackResult.cleanup()
anyBindResult.cleanup()
remoteResult.cleanup()
expect(mockServerClose).not.toHaveBeenCalled()
})
it("attach mode skips auth injection for invalid attach URLs", async () => {
// given
const signal = new AbortController().signal
const attachUrl = "not-a-url"
// when
const result = await createServerConnection({ attach: attachUrl, signal })
// then
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
expect(mockInjectServerAuthIntoClient).not.toHaveBeenCalled()
result.cleanup()
expect(mockServerClose).not.toHaveBeenCalled()
})
it("attach mode returns client with no-op cleanup", async () => {
// given
const signal = new AbortController().signal
@@ -68,8 +122,6 @@ describe("createServerConnection", () => {
const result = await createServerConnection({ attach: attachUrl, signal })
// then
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
expect(result.client).toBeDefined()
expect(result.cleanup).toBeDefined()
result.cleanup()
+18
View File
@@ -1,9 +1,21 @@
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
import pc from "picocolors"
import type { ServerConnection } from "./types"
import { injectServerAuthIntoClient } from "../../shared/opencode-server-auth"
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0"])
function isLoopbackAttachUrl(url: string): boolean {
try {
const parsed = new URL(url)
return LOOPBACK_HOSTS.has(parsed.hostname)
} catch {
return false
}
}
function isPortStartFailure(error: unknown, port: number): boolean {
if (!(error instanceof Error)) {
return false
@@ -40,6 +52,9 @@ export async function createServerConnection(options: {
if (attach !== undefined) {
console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
const client = createOpencodeClient({ baseUrl: attach })
if (isLoopbackAttachUrl(attach)) {
injectServerAuthIntoClient(client)
}
return { client, cleanup: () => {} }
}
@@ -66,12 +81,14 @@ export async function createServerConnection(options: {
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
injectServerAuthIntoClient(client)
return { client, cleanup: () => {} }
}
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
injectServerAuthIntoClient(client)
return { client, cleanup: () => {} }
}
@@ -93,6 +110,7 @@ export async function createServerConnection(options: {
console.log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` })
injectServerAuthIntoClient(client)
return { client, cleanup: () => {} }
}
+1 -1
View File
@@ -74,7 +74,7 @@ export async function promptInstallConfig(detected: DetectedConfig): Promise<Ins
message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [
{ value: "no", label: "No", hint: "Will use other configured providers" },
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.4, etc." },
{ value: "yes", label: "Yes", hint: "opencode/claude-opus-4-7, opencode/gpt-5.4, etc." },
],
initialValue: initial.opencodeZen,
})
+1 -1
View File
@@ -1,6 +1,6 @@
# src/config/ — Zod v4 Schema System
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
-24
View File
@@ -27,30 +27,6 @@ describe("BackgroundTaskConfigSchema", () => {
})
})
describe("maxDescendants", () => {
describe("#given valid maxDescendants (50)", () => {
test("#when parsed #then returns correct value", () => {
const result = BackgroundTaskConfigSchema.parse({ maxDescendants: 50 })
expect(result.maxDescendants).toBe(50)
})
})
describe("#given maxDescendants below minimum (0)", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({ maxDescendants: 0 })
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})
describe("syncPollTimeoutMs", () => {
describe("#given valid syncPollTimeoutMs (120000)", () => {
test("#when parsed #then returns correct value", () => {
-1
View File
@@ -11,7 +11,6 @@ export const BackgroundTaskConfigSchema = z.object({
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
maxDepth: z.number().int().min(1).optional(),
maxDescendants: z.number().int().min(1).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
+4
View File
@@ -2,6 +2,7 @@ import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"
import type { HookName, OhMyOpenCodeConfig } from "./config"
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
import type { BackgroundManager } from "./features/background-agent"
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
import type { PluginContext } from "./plugin/types"
import type { ModelCacheState } from "./plugin-state"
@@ -36,6 +37,7 @@ export function createHooks(args: {
pluginConfig: OhMyOpenCodeConfig
modelCacheState: ModelCacheState
backgroundManager: BackgroundManager
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
mergedSkills: LoadedSkill[]
@@ -46,6 +48,7 @@ export function createHooks(args: {
pluginConfig,
modelCacheState,
backgroundManager,
modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
mergedSkills,
@@ -56,6 +59,7 @@ export function createHooks(args: {
ctx,
pluginConfig,
modelCacheState,
modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
})
+5
View File
@@ -5,6 +5,7 @@ import type { PluginContext, TmuxConfig } from "./plugin/types"
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
import { BackgroundManager } from "./features/background-agent"
import { SkillMcpManager } from "./features/skill-mcp-manager"
import { createModelFallbackControllerAccessor } from "./hooks/model-fallback"
import { initTaskToastManager } from "./features/task-toast-manager"
import { TmuxSessionManager } from "./features/tmux-subagent"
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
@@ -12,6 +13,7 @@ import { registerManagerForCleanup } from "./features/background-agent/process-c
import { createConfigHandler } from "./plugin-handlers"
import { log } from "./shared"
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
type CreateManagersDeps = {
BackgroundManagerClass: typeof BackgroundManager
@@ -38,6 +40,7 @@ export type Managers = {
backgroundManager: BackgroundManager
skillMcpManager: SkillMcpManager
configHandler: ReturnType<typeof createConfigHandler>
modelFallbackControllerAccessor: ModelFallbackControllerAccessor
}
export function createManagers(args: {
@@ -119,11 +122,13 @@ export function createManagers(args: {
pluginConfig,
modelCacheState,
})
const modelFallbackControllerAccessor = createModelFallbackControllerAccessor()
return {
tmuxSessionManager,
backgroundManager,
skillMcpManager,
configHandler,
modelFallbackControllerAccessor,
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ type CreateToolsResult = {
export async function createTools(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
}): Promise<CreateToolsResult> {
const { ctx, pluginConfig, managers } = args
+1 -1
View File
@@ -1,6 +1,6 @@
# src/features/ — 19 Feature Modules
**Generated:** 2026-04-11
**Generated:** 2026-04-18
## OVERVIEW
+1 -1
View File
@@ -44,7 +44,7 @@ Both must agree before marking a task complete. Prevents premature completion on
## CONCURRENCY MODEL
- Key format: `{providerID}/{modelID}` (e.g., `anthropic/claude-opus-4-6`)
- Key format: `{providerID}/{modelID}` (e.g., `anthropic/claude-opus-4-7`)
- Default limit: 5 concurrent per key (configurable via `background_task` config)
- FIFO queue: tasks wait in order when slots full
- Slot released on: completion, error, cancellation
@@ -83,7 +83,7 @@ describe("findNearestMessageExcludingCompaction", () => {
// given
const message = {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
writeFileSync(join(tempDir, "001.json"), JSON.stringify(message))
@@ -94,18 +94,18 @@ describe("findNearestMessageExcludingCompaction", () => {
expect(result).not.toBeNull()
expect(result?.agent).toBe("sisyphus")
expect(result?.model?.providerID).toBe("anthropic")
expect(result?.model?.modelID).toBe("claude-opus-4-6")
expect(result?.model?.modelID).toBe("claude-opus-4-7")
})
test("skips compaction agent messages", () => {
// given
const compactionMessage = {
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
const validMessage = {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
writeFileSync(join(tempDir, "002.json"), JSON.stringify(compactionMessage))
writeFileSync(join(tempDir, "001.json"), JSON.stringify(validMessage))
@@ -125,12 +125,12 @@ describe("findNearestMessageExcludingCompaction", () => {
writeFileSync(join(tempDir, "002.json"), JSON.stringify({
id: compactionMessageID,
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}))
writeFileSync(join(tempDir, "001.json"), JSON.stringify({
id: "msg_001",
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}))
mkdirSync(partDir, { recursive: true })
writeFileSync(join(partDir, "prt_0001.json"), JSON.stringify({ type: "compaction" }))
@@ -94,7 +94,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
// when
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-6")
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-7")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3.1-pro")
// then
+2
View File
@@ -1,2 +1,4 @@
export * from "./types"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { waitForTaskSessionID } from "./wait-for-task-session"
export type { WaitForTaskSessionIDOptions } from "./wait-for-task-session"
+43 -48
View File
@@ -855,7 +855,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
{
info: {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
},
},
{
@@ -890,7 +890,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
//#then
expect(capturedBody?.agent).toBe("sisyphus")
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.6" })
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
manager.shutdown()
})
@@ -913,7 +913,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
}
const currentMessage: CurrentMessage = {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
}
// when
@@ -921,7 +921,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
// then - uses currentMessage values, not task.parentModel/parentAgent
expect(promptBody.agent).toBe("sisyphus")
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.6" })
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
})
test("should fallback to parentAgent when currentMessage.agent is undefined", async () => {
@@ -1155,7 +1155,7 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
agent: "explore",
model: {
providerID: "anthropic",
modelID: "claude-opus-4.6",
modelID: "claude-opus-4.7",
variant: "high",
},
},
@@ -1211,7 +1211,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
agent: "explore",
model: {
providerID: "anthropic",
modelID: "claude-opus-4.6",
modelID: "claude-opus-4.7",
variant: "max",
},
},
@@ -1231,7 +1231,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
model: { providerID: "anthropic", modelID: "claude-opus-4.6", variant: "high" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7", variant: "high" },
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
@@ -1272,7 +1272,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
@@ -1349,7 +1349,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should release concurrency and clear key on completion", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4.6"
const concurrencyKey = "anthropic/claude-opus-4.7"
const concurrencyManager = getConcurrencyManager(manager)
await concurrencyManager.acquire(concurrencyKey)
@@ -1378,7 +1378,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should prevent double completion and double release", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4.6"
const concurrencyKey = "anthropic/claude-opus-4.7"
const concurrencyManager = getConcurrencyManager(manager)
await concurrencyManager.acquire(concurrencyKey)
@@ -1508,7 +1508,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should release task concurrencyKey when startTask throws after assigning it", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4.6"
const concurrencyKey = "anthropic/claude-opus-4.7"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
@@ -1524,7 +1524,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
@@ -1544,7 +1544,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should mark task as error when startTask throws after session creation", async () => {
//#given - startTask creates session but fails before sending prompt
const concurrencyKey = "anthropic/claude-opus-4.6"
const concurrencyKey = "anthropic/claude-opus-4.7"
const task = createMockTask({
id: "task-zombie-session",
@@ -1561,7 +1561,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
@@ -1585,7 +1585,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should release queue slot when queued task is already interrupt", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4.6"
const concurrencyKey = "anthropic/claude-opus-4.7"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
@@ -1601,7 +1601,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
@@ -2104,7 +2104,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
agent: "test-agent",
parentSessionID: "parent-session",
parentMessageID: "parent-message",
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
}
const launchInputWithoutModel = {
description: "Test task without model",
@@ -2124,7 +2124,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(taskWithModel.status).toBe("pending")
expect(taskWithoutModel.status).toBe("pending")
expect(promptBodies).toHaveLength(2)
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.6" })
expect(promptBodies[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4.7" })
expect(promptBodies[0].agent).toBe("test-agent")
expect(promptBodies[1].agent).toBe("test-agent")
expect("model" in promptBodies[1]).toBe(false)
@@ -2327,7 +2327,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
await expect(result).rejects.toThrow("background_task.maxDepth=3")
})
test("should block launches when maxDescendants is reached", async () => {
test("allows multiple descendants without a root spawn cap", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
@@ -2337,7 +2337,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
@@ -2354,10 +2353,10 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
await expect(result).resolves.toBeDefined()
})
test("should consume descendant quota for reserved sync spawns", async () => {
test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
@@ -2367,7 +2366,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
await manager.reserveSubagentSpawn("session-root")
@@ -2376,7 +2374,10 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.assertCanSpawn("session-root")
// then
await expect(result).rejects.toThrow("background_task.maxDescendants=1")
await expect(result).resolves.toMatchObject({
rootSessionID: "session-root",
childDepth: 1,
})
})
test("should fail closed when session lineage lookup fails", async () => {
@@ -2392,7 +2393,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
@@ -2407,10 +2407,10 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const result = manager.launch(input)
// then
await expect(result).rejects.toThrow("background_task.maxDescendants cannot be enforced safely")
await expect(result).rejects.toThrow("background_task.maxDepth cannot be enforced safely")
})
test("should release descendant quota when queued task is cancelled before session starts", async () => {
test("allows replacement launch when a queued task is cancelled before session starts", async () => {
// given
manager.shutdown()
manager = new BackgroundManager(
@@ -2420,7 +2420,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ defaultConcurrency: 1, maxDescendants: 2 },
{ defaultConcurrency: 1 },
)
const input = {
@@ -2445,7 +2445,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(replacementTask.status).toBe("pending")
})
test("should release descendant quota when session creation fails before session starts", async () => {
test("allows retry after session creation fails before session starts", async () => {
// given
let createAttempts = 0
manager.shutdown()
@@ -2472,7 +2472,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
},
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
@@ -2887,7 +2886,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}
})
test("should release descendant quota when task completes", async () => {
test("allows relaunch after task completes", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
@@ -2896,7 +2895,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
stubNotifyParentSession(manager)
@@ -2920,7 +2918,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
await expect(manager.launch(input)).resolves.toBeDefined()
})
test("should release descendant quota when running task is cancelled", async () => {
test("allows relaunch after running task is cancelled", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
@@ -2929,7 +2927,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
@@ -2950,7 +2947,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
await expect(manager.launch(input)).resolves.toBeDefined()
})
test("should release descendant quota when task errors", async () => {
test("allows relaunch after task errors", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
@@ -2959,7 +2956,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
@@ -2984,7 +2980,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
await expect(manager.launch(input)).resolves.toBeDefined()
})
test("should not double-decrement quota when pending task is cancelled", async () => {
test("allows repeated relaunch after pending tasks are cancelled", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
@@ -2993,7 +2989,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 2 },
)
const input = {
@@ -3250,7 +3245,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
description: "Task 1",
prompt: "Do something",
agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-opus-4.6" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7" },
parentSessionID: "parent-session",
parentMessageID: "parent-message",
}
@@ -4230,7 +4225,7 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
describe("BackgroundManager.handleEvent - session.error", () => {
const defaultRetryFallbackChain = [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["anthropic"], model: "gpt-5.3-codex", variant: "high" },
]
@@ -4254,7 +4249,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
agent: "sisyphus",
status: "running",
concurrencyKey: input.concurrencyKey,
model: { providerID: "anthropic", modelID: "claude-opus-4.6-thinking" },
model: { providerID: "anthropic", modelID: "claude-opus-4.7-thinking" },
fallbackChain: input.fallbackChain ?? defaultRetryFallbackChain,
attemptCount: 0,
})
@@ -4399,7 +4394,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "anthropic/claude-opus-4.6-thinking"
const concurrencyKey = "anthropic/claude-opus-4.7-thinking"
await concurrencyManager.acquire(concurrencyKey)
stubProcessKey(manager)
@@ -4411,7 +4406,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
description: "task that should retry",
concurrencyKey,
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["anthropic"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["anthropic"], model: "claude-opus-4-5", variant: "max" },
],
})
@@ -4425,7 +4420,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.6-thinking\"}}",
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.7-thinking\"}}",
},
},
},
@@ -4436,7 +4431,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4.6",
modelID: "claude-opus-4.7",
variant: "max",
})
expect(task.concurrencyKey).toBeUndefined()
@@ -4474,7 +4469,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4.6",
modelID: "claude-opus-4.7",
variant: "max",
})
@@ -4502,7 +4497,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.6-thinking\"}}",
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4.7-thinking\"}}",
},
},
}
@@ -4519,7 +4514,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
expect(task.attemptCount).toBe(1)
expect(task.model).toEqual({
providerID: "anthropic",
modelID: "claude-opus-4.6",
modelID: "claude-opus-4.7",
variant: "max",
})
-12
View File
@@ -73,8 +73,6 @@ import {
} from "./loop-detector"
import {
createSubagentDepthLimitError,
createSubagentDescendantLimitError,
getMaxRootSessionSpawnBudget,
getMaxSubagentDepth,
resolveSubagentSpawnContext,
type SubagentSpawnContext,
@@ -219,16 +217,6 @@ export class BackgroundManager {
})
}
const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
if (descendantCount >= maxRootSessionSpawnBudget) {
throw createSubagentDescendantLimitError({
rootSessionID: spawnContext.rootSessionID,
descendantCount,
maxDescendants: maxRootSessionSpawnBudget,
})
}
return spawnContext
}
@@ -0,0 +1,27 @@
type ProcessCleanupEvent =
| NodeJS.Signals
| "beforeExit"
| "exit"
| "uncaughtException"
| "unhandledRejection"
export function getNewListener(
signal: ProcessCleanupEvent,
existingListeners: Function[],
): () => void {
const listener = process
.listeners(signal)
.find((registeredListener) => !existingListeners.includes(registeredListener))
if (typeof listener !== "function") {
throw new Error(`Expected a ${signal} listener to be registered`)
}
return listener
}
export async function flushMicrotasks(): Promise<void> {
for (let iteration = 0; iteration < 10; iteration += 1) {
await Promise.resolve()
}
}
@@ -1,3 +1,5 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import {
@@ -5,42 +7,17 @@ import {
registerManagerForCleanup,
unregisterManagerForCleanup,
} from "./process-cleanup"
import { flushMicrotasks, getNewListener } from "./process-cleanup.test-helpers"
type CleanupManager = {
shutdown: () => void | Promise<void>
}
type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
function getNewListener(
signal: ProcessCleanupEvent,
existingListeners: Function[],
): () => void {
const listener = process
.listeners(signal)
.find((registeredListener) => !existingListeners.includes(registeredListener))
expect(listener).toBeDefined()
if (typeof listener !== "function") {
throw new Error(`Expected a ${signal} listener to be registered`)
}
return listener
}
async function flushMicrotasks(): Promise<void> {
for (let iteration = 0; iteration < 10; iteration += 1) {
await Promise.resolve()
}
}
describe("#given process cleanup registration", () => {
const registeredManagers: CleanupManager[] = []
const originalExitCode = process.exitCode
beforeEach(() => {
process.exitCode = originalExitCode
process.exitCode = 0
registeredManagers.length = 0
_resetForTesting()
})
@@ -50,7 +27,7 @@ describe("#given process cleanup registration", () => {
unregisterManagerForCleanup(manager)
}
process.exitCode = originalExitCode
process.exitCode = 0
_resetForTesting()
})
@@ -92,13 +69,7 @@ describe("#given process cleanup registration", () => {
test("#when cleanup finishes after SIGINT #then the fallback exit timer is cleared", async () => {
const sigintListenersBefore = process.listeners("SIGINT")
const timeoutHandle = setTimeout(() => undefined, 0)
clearTimeout(timeoutHandle)
const setTimeoutImplementation: typeof setTimeout = () => timeoutHandle
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(
setTimeoutImplementation,
)
const setTimeoutSpy = spyOn(globalThis, "setTimeout")
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
try {
@@ -117,11 +88,10 @@ describe("#given process cleanup registration", () => {
await flushMicrotasks()
expect(setTimeoutSpy).toHaveBeenCalledTimes(1)
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle)
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1)
} finally {
setTimeoutSpy.mockRestore()
clearTimeoutSpy.mockRestore()
clearTimeout(timeoutHandle)
}
})
})
@@ -163,6 +133,32 @@ describe("#given process cleanup registration", () => {
expect(process.listeners("SIGINT")).toHaveLength(sigintListenersAfterFirstRegistration)
})
test("#given two managers registered #when uncaughtException fires #then both shutdowns called", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const shutdownOne = mock(() => {})
const shutdownTwo = mock(() => {})
const managerOne = { shutdown: shutdownOne }
const managerTwo = { shutdown: shutdownTwo }
registeredManagers.push(managerOne, managerTwo)
try {
registerManagerForCleanup(managerOne)
registerManagerForCleanup(managerTwo)
process.emit("uncaughtException", new Error("boom"))
await flushMicrotasks()
expect(shutdownOne).toHaveBeenCalledTimes(1)
expect(shutdownTwo).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
})
describe("#given cleanup managers are unregistered", () => {
@@ -202,5 +198,88 @@ describe("#given process cleanup registration", () => {
expect(remainingManagerShutdown).toHaveBeenCalledTimes(1)
expect(removedManagerShutdown).not.toHaveBeenCalled()
})
test("#given uncaughtException handler registered #when manager is unregistered via unregisterManagerForCleanup #then subsequent events do not invoke that manager", () => {
const uncaughtExceptionListenersBefore = process.listeners("uncaughtException")
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
registerManagerForCleanup(manager)
expect(process.listeners("uncaughtException")).toHaveLength(
uncaughtExceptionListenersBefore.length + 1,
)
unregisterManagerForCleanup(manager)
registeredManagers.length = 0
process.emit("uncaughtException", new Error("boom"))
expect(shutdown).not.toHaveBeenCalled()
})
})
describe("#given uncaught exception and rejection cleanup", () => {
test("#given manager registered AND process emits uncaughtException #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
try {
registerManagerForCleanup(manager)
process.emit("uncaughtException", new Error("boom"))
await flushMicrotasks()
expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
test("#given manager registered AND process emits unhandledRejection #when event fires #then manager.shutdown() called AND process.exitCode set to 1", async () => {
const exitSpy = spyOn(process, "exit").mockImplementation((code?: number): never => {
throw new Error(`Unexpected process.exit(${String(code)})`)
})
const shutdown = mock(() => {})
const manager = { shutdown }
registeredManagers.push(manager)
try {
registerManagerForCleanup(manager)
process.emit("unhandledRejection", new Error("boom"), Promise.resolve())
await flushMicrotasks()
expect(shutdown).toHaveBeenCalledTimes(1)
expect(process.exitCode).toBe(1)
expect(exitSpy).not.toHaveBeenCalled()
} finally {
exitSpy.mockRestore()
}
})
test("#given _resetForTesting() called #when event fires #then no cleanup runs", () => {
const uncaughtExceptionListenersBefore = process.listeners("uncaughtException")
const shutdown = mock(() => {})
const manager = { shutdown }
registerManagerForCleanup(manager)
expect(process.listeners("uncaughtException")).toHaveLength(
uncaughtExceptionListenersBefore.length + 1,
)
_resetForTesting()
process.emit("uncaughtException", new Error("boom"))
expect(shutdown).not.toHaveBeenCalled()
expect(process.listeners("uncaughtException")).toHaveLength(
uncaughtExceptionListenersBefore.length,
)
})
})
})
@@ -1,33 +1,51 @@
import { log } from "../../shared"
type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupSignal = NodeJS.Signals | "beforeExit" | "exit"
type ProcessCleanupErrorEvent = "uncaughtException" | "unhandledRejection"
function scheduleForcedExit(cleanupResult: void | Promise<void>, exitCode: number): void {
process.exitCode = exitCode
const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
})
}
function registerProcessSignal(
signal: ProcessCleanupEvent,
signal: ProcessCleanupSignal,
handler: () => void | Promise<void>,
exitAfter: boolean
): () => void {
const listener = () => {
const cleanupResult = handler()
if (exitAfter) {
process.exitCode = 0
const exitTimeout = setTimeout(() => process.exit(), 6000)
void Promise.resolve(cleanupResult).finally(() => {
clearTimeout(exitTimeout)
})
scheduleForcedExit(cleanupResult, 0)
}
}
process.on(signal, listener)
return listener
}
function registerErrorEvent(
signal: ProcessCleanupErrorEvent,
handler: (error: unknown) => void | Promise<void>
): (error: unknown) => void {
const listener = (error: unknown) => {
log(`[background-agent] ${signal} received during shutdown cleanup:`, error)
scheduleForcedExit(handler(error), 1)
}
process.on(signal, listener)
return listener
}
interface CleanupTarget {
shutdown(): void | Promise<void>
}
const cleanupManagers = new Set<CleanupTarget>()
let cleanupRegistered = false
const cleanupHandlers = new Map<ProcessCleanupEvent, () => void>()
const cleanupSignalHandlers = new Map<ProcessCleanupSignal, () => void>()
const cleanupErrorHandlers = new Map<ProcessCleanupErrorEvent, (error: unknown) => void>()
export function registerManagerForCleanup(manager: CleanupTarget): void {
cleanupManagers.add(manager)
@@ -59,9 +77,9 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
return cleanupPromise
}
const registerSignal = (signal: ProcessCleanupEvent, exitAfter: boolean): void => {
const registerSignal = (signal: ProcessCleanupSignal, exitAfter: boolean): void => {
const listener = registerProcessSignal(signal, cleanupAll, exitAfter)
cleanupHandlers.set(signal, listener)
cleanupSignalHandlers.set(signal, listener)
}
registerSignal("SIGINT", true)
@@ -71,6 +89,8 @@ export function registerManagerForCleanup(manager: CleanupTarget): void {
}
registerSignal("beforeExit", false)
registerSignal("exit", false)
cleanupErrorHandlers.set("uncaughtException", registerErrorEvent("uncaughtException", cleanupAll))
cleanupErrorHandlers.set("unhandledRejection", registerErrorEvent("unhandledRejection", cleanupAll))
}
export function unregisterManagerForCleanup(manager: CleanupTarget): void {
@@ -78,10 +98,14 @@ export function unregisterManagerForCleanup(manager: CleanupTarget): void {
if (cleanupManagers.size > 0) return
for (const [signal, listener] of cleanupHandlers.entries()) {
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
cleanupHandlers.clear()
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}
@@ -90,9 +114,13 @@ export function _resetForTesting(): void {
for (const manager of [...cleanupManagers]) {
cleanupManagers.delete(manager)
}
for (const [signal, listener] of cleanupHandlers.entries()) {
for (const [signal, listener] of cleanupSignalHandlers.entries()) {
process.off(signal, listener)
}
cleanupHandlers.clear()
for (const [signal, listener] of cleanupErrorHandlers.entries()) {
process.off(signal, listener)
}
cleanupSignalHandlers.clear()
cleanupErrorHandlers.clear()
cleanupRegistered = false
}
@@ -577,3 +577,84 @@ describe("background-agent spawner fallback model promotion", () => {
expect(promptCalls[0]?.body?.agent).toBe("sisyphus-junior")
})
})
describe("background-agent spawner tmux callback ordering", () => {
test("fires promptAsync before tmux callback resolves (no blocking)", async () => {
//#given
const events: string[] = []
let resolveTmuxCallback: () => void = () => {}
const tmuxCallbackPromise = new Promise<void>((resolve) => {
resolveTmuxCallback = resolve
})
const client = {
session: {
get: async () => ({ data: { directory: "/tmp/test" } }),
create: async () => {
events.push("session.create")
return { data: { id: "ses_blocking_tmux" } }
},
promptAsync: async () => {
events.push("promptAsync")
return { data: {} }
},
},
} as any
const onSubagentSessionCreated = mock(async () => {
events.push("tmux.callback.start")
await tmuxCallbackPromise
events.push("tmux.callback.end")
})
const task = createTask({
description: "Blocking tmux test",
prompt: "Do work",
agent: "general",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
},
}
const ctx = {
client,
directory: "/tmp/test",
concurrencyManager: { release: () => {} },
tmuxEnabled: true,
onSubagentSessionCreated,
onTaskError: () => {},
}
const originalTmux = process.env.TMUX
process.env.TMUX = "/tmp/fake-tmux-socket"
try {
//#when
await startTask(item as any, ctx as any)
await new Promise((resolve) => setTimeout(resolve, 20))
//#then
expect(events).toContain("session.create")
expect(events).toContain("promptAsync")
expect(events).toContain("tmux.callback.start")
const promptIdx = events.indexOf("promptAsync")
const tmuxStartIdx = events.indexOf("tmux.callback.start")
expect(promptIdx < tmuxStartIdx).toBe(true)
expect(events).not.toContain("tmux.callback.end")
} finally {
resolveTmuxCallback()
if (originalTmux === undefined) delete process.env.TMUX
else process.env.TMUX = originalTmux
}
})
})
+25 -25
View File
@@ -1,6 +1,5 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
@@ -115,29 +114,6 @@ export async function startTask(
const sessionID = createResult.data.id
subagentSessions.add(sessionID)
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
sessionID,
parentID: input.parentSessionID,
})
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await onSubagentSessionCreated({
sessionID,
parentID: input.parentSessionID,
title: input.description,
}).catch((err) => {
log("[background-agent] Failed to spawn tmux pane:", err)
})
log("[background-agent] tmux callback completed, waiting")
await new Promise(r => setTimeout(r, TMUX_CALLBACK_DELAY_MS))
} else {
log("[background-agent] SKIP tmux callback - conditions not met")
}
task.status = "running"
task.startedAt = new Date()
task.sessionID = sessionID
@@ -188,7 +164,8 @@ export async function startTask(
parts: [createInternalAgentTextPart(input.prompt)],
}
promptWithModelSuggestionRetry(client, {
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
const promptChain = promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: promptBody,
}).catch(async (error) => {
@@ -214,6 +191,29 @@ export async function startTask(
log("[background-agent] promptAsync error:", error)
onTaskError(task, error instanceof Error ? error : new Error(String(error)))
})
void promptChain
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
sessionID,
parentID: input.parentSessionID,
})
if (onSubagentSessionCreated && tmuxEnabled && isInsideTmux()) {
log("[background-agent] Invoking tmux callback (fire-and-forget)", { sessionID })
void onSubagentSessionCreated({
sessionID,
parentID: input.parentSessionID,
title: input.description,
}).catch((err) => {
log("[background-agent] Failed to spawn tmux pane:", err)
})
} else {
log("[background-agent] SKIP tmux callback - conditions not met")
}
}
export async function resumeTask(
@@ -5,9 +5,6 @@ import {
getMaxSubagentDepth,
DEFAULT_MAX_SUBAGENT_DEPTH,
createSubagentDepthLimitError,
createSubagentDescendantLimitError,
getMaxRootSessionSpawnBudget,
DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET,
} from "./subagent-spawn-limits"
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
@@ -62,7 +59,7 @@ describe("resolveSubagentSpawnContext", () => {
const result = resolveSubagentSpawnContext(client, "parent-session")
// then
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*lookup failed/)
await expect(result).rejects.toThrow(/background_task\.maxDepth cannot be enforced safely.*lookup failed/)
})
})
@@ -77,7 +74,7 @@ describe("resolveSubagentSpawnContext", () => {
const result = resolveSubagentSpawnContext(client, "parent-session")
// then
await expect(result).rejects.toThrow(/background_task\.maxDescendants cannot be enforced safely.*No session data returned/)
await expect(result).rejects.toThrow(/background_task\.maxDepth cannot be enforced safely.*No session data returned/)
})
})
@@ -209,20 +206,6 @@ describe("getMaxSubagentDepth", () => {
})
})
describe("getMaxRootSessionSpawnBudget", () => {
test("returns DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET when no config", () => {
expect(getMaxRootSessionSpawnBudget()).toBe(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET)
})
test("returns config.maxDescendants when provided", () => {
expect(getMaxRootSessionSpawnBudget({ maxDescendants: 10 })).toBe(10)
})
test("default is 50", () => {
expect(DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET).toBe(50)
})
})
describe("createSubagentDepthLimitError", () => {
test("includes childDepth, maxDepth, and session IDs in message", () => {
const error = createSubagentDepthLimitError({
@@ -239,18 +222,3 @@ describe("createSubagentDepthLimitError", () => {
expect(error.message).toContain("spawn blocked")
})
})
describe("createSubagentDescendantLimitError", () => {
test("includes descendant count, max, and root session ID", () => {
const error = createSubagentDescendantLimitError({
rootSessionID: "root-789",
descendantCount: 50,
maxDescendants: 50,
})
expect(error.message).toContain("root-789")
expect(error.message).toContain("50")
expect(error.message).toContain("maxDescendants=50")
expect(error.message).toContain("spawn blocked")
})
})
@@ -2,7 +2,6 @@ import type { BackgroundTaskConfig } from "../../config/schema"
import type { OpencodeClient } from "./constants"
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3
export const DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET = 50
export interface SubagentSpawnContext {
rootSessionID: string
@@ -14,10 +13,6 @@ export function getMaxSubagentDepth(config?: BackgroundTaskConfig): number {
return config?.maxDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH
}
export function getMaxRootSessionSpawnBudget(config?: BackgroundTaskConfig): number {
return config?.maxDescendants ?? DEFAULT_MAX_ROOT_SESSION_SPAWN_BUDGET
}
export async function resolveSubagentSpawnContext(
client: OpencodeClient,
parentSessionID: string,
@@ -53,7 +48,7 @@ export async function resolveSubagentSpawnContext(
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDescendants cannot be enforced safely. ${reason}`
`Subagent spawn blocked: failed to resolve session lineage for ${parentSessionID}, so background_task.maxDepth cannot be enforced safely. ${reason}`
)
}
@@ -84,14 +79,3 @@ export function createSubagentDepthLimitError(input: {
`Subagent spawn blocked: child depth ${childDepth} exceeds background_task.maxDepth=${maxDepth}. Parent session: ${parentSessionID}. Root session: ${rootSessionID}. Continue in an existing subagent session instead of spawning another.`
)
}
export function createSubagentDescendantLimitError(input: {
rootSessionID: string
descendantCount: number
maxDescendants: number
}): Error {
const { rootSessionID, descendantCount, maxDescendants } = input
return new Error(
`Subagent spawn blocked: root session ${rootSessionID} already has ${descendantCount} descendants, which meets background_task.maxDescendants=${maxDescendants}. Reuse an existing session instead of spawning another.`
)
}
@@ -648,7 +648,7 @@ describe("checkAndInterruptStaleTasks", () => {
const task = createRunningTask({
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
concurrencyKey: "anthropic/claude-opus-4-6",
concurrencyKey: "anthropic/claude-opus-4-7",
})
//#when
@@ -661,7 +661,7 @@ describe("checkAndInterruptStaleTasks", () => {
})
//#then
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-7")
expect(task.concurrencyKey).toBeUndefined()
})
@@ -0,0 +1,95 @@
import { describe, expect, test } from "bun:test"
import type { BackgroundTaskStatus } from "./types"
import { waitForTaskSessionID } from "./wait-for-task-session"
interface TaskSnapshot {
sessionID?: string
status?: BackgroundTaskStatus
}
function createManager(responses: TaskSnapshot[]) {
let index = 0
return {
getTask(_taskID: string): TaskSnapshot {
const response = responses[Math.min(index, responses.length - 1)]
index += 1
return response
},
}
}
describe("waitForTaskSessionID", () => {
test("#given task already has a session id #when waiting #then it returns immediately", async () => {
// given
const manager = createManager([{ sessionID: "ses_ready_123", status: "running" }])
// when
const sessionID = await waitForTaskSessionID(manager, "bg_ready")
// then
expect(sessionID).toBe("ses_ready_123")
})
test("#given session appears later #when waiting #then it polls until resolved", async () => {
// given
const manager = createManager([
{ status: "running" },
{ status: "running" },
{ sessionID: "ses_late_123", status: "running" },
])
// when
const sessionID = await waitForTaskSessionID(manager, "bg_late", {
intervalMs: 1,
timeoutMs: 20,
})
// then
expect(sessionID).toBe("ses_late_123")
})
test("#given aborted signal #when waiting #then it returns undefined", async () => {
// given
const controller = new AbortController()
controller.abort()
const manager = createManager([{ status: "running" }])
// when
const sessionID = await waitForTaskSessionID(manager, "bg_abort", {
signal: controller.signal,
})
// then
expect(sessionID).toBeUndefined()
})
test("#given task never resolves #when waiting past timeout #then it returns undefined", async () => {
// given
const manager = createManager([{ status: "running" }, { status: "running" }, { status: "running" }])
// when
const sessionID = await waitForTaskSessionID(manager, "bg_timeout", {
intervalMs: 1,
timeoutMs: 3,
})
// then
expect(sessionID).toBeUndefined()
})
test.each(["error", "cancelled", "interrupt"] satisfies BackgroundTaskStatus[])(
"#given %s task state #when waiting #then it returns undefined",
async (status: BackgroundTaskStatus) => {
// given
const manager = createManager([{ status }])
// when
const sessionID = await waitForTaskSessionID(manager, `bg_${status}`)
// then
expect(sessionID).toBeUndefined()
}
)
})
@@ -0,0 +1,68 @@
import { getTimingConfig } from "../../tools/delegate-task/timing"
import type { BackgroundTaskStatus } from "./types"
type SessionWaitTerminalStatus = Extract<BackgroundTaskStatus, "error" | "cancelled" | "interrupt">
type AbortSignalLike = { aborted: boolean }
interface TaskReader {
getTask(taskID: string): { sessionID?: string; status?: BackgroundTaskStatus } | undefined
}
export interface WaitForTaskSessionIDOptions {
timeoutMs?: number
intervalMs?: number
signal?: AbortSignalLike
}
function isTerminalStatus(status: BackgroundTaskStatus | undefined): status is SessionWaitTerminalStatus {
return status === "error" || status === "cancelled" || status === "interrupt"
}
function waitForInterval(intervalMs: number): Promise<void> {
return new Promise(resolve => {
const scheduler = globalThis as { setTimeout: (handler: () => void, timeout?: number) => unknown }
scheduler.setTimeout(resolve, intervalMs)
})
}
export async function waitForTaskSessionID(
manager: TaskReader,
taskID: string,
options: WaitForTaskSessionIDOptions = {}
): Promise<string | undefined> {
const timing = getTimingConfig()
const timeoutMs = options.timeoutMs ?? timing.WAIT_FOR_SESSION_TIMEOUT_MS
const intervalMs = options.intervalMs ?? timing.WAIT_FOR_SESSION_INTERVAL_MS
if (options.signal?.aborted) {
return undefined
}
const initialTask = manager.getTask(taskID)
if (initialTask?.sessionID) {
return initialTask.sessionID
}
if (isTerminalStatus(initialTask?.status)) {
return undefined
}
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (options.signal?.aborted) {
return undefined
}
await waitForInterval(intervalMs)
const task = manager.getTask(taskID)
if (task?.sessionID) {
return task.sessionID
}
if (isTerminalStatus(task?.status)) {
return undefined
}
}
return undefined
}
@@ -23,8 +23,8 @@ describe("mapClaudeModelToOpenCode", () => {
expect(mapClaudeModelToOpenCode("sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
it("#when called with opus #then maps to anthropic claude-opus-4-6 object", () => {
expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
it("#when called with opus #then maps to anthropic claude-opus-4-7 object", () => {
expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
})
it("#when called with haiku #then maps to anthropic claude-haiku-4-5 object", () => {
@@ -47,8 +47,8 @@ describe("mapClaudeModelToOpenCode", () => {
expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-5-20250514" })
})
it("#when called with claude-opus-4-6 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
it("#when called with claude-opus-4-7 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-opus-4-7")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
})
it("#when called with claude-haiku-4-5-20251001 #then adds anthropic object format", () => {
@@ -5,7 +5,7 @@ const ANTHROPIC_PREFIX = "anthropic/"
const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`],
["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`],
["opus", `${ANTHROPIC_PREFIX}claude-opus-4-7`],
["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`],
])
@@ -38,7 +38,7 @@ describe("readOpencodeConfigAgents", () => {
agents: {
"my-agent": {
description: "Custom agent",
model: "claude-opus-4-6",
model: "claude-opus-4-7",
mode: "subagent",
prompt: "You are a helpful assistant",
},
@@ -0,0 +1,37 @@
import { promises as fs } from "fs"
import { resolve } from "path"
import type { CommandDefinition } from "./types"
const commandLoaderCache = new Map<string, Promise<Record<string, CommandDefinition>>>()
export async function getCommandLoaderCacheKey(directory?: string): Promise<string> {
const resolvedDirectory = resolve(directory ?? process.cwd())
try {
return await fs.realpath(resolvedDirectory)
} catch {
return resolvedDirectory
}
}
export function getCachedCommands(
cacheKey: string,
): Promise<Record<string, CommandDefinition>> | undefined {
return commandLoaderCache.get(cacheKey)
}
export function setCachedCommands(
cacheKey: string,
commands: Promise<Record<string, CommandDefinition>>,
): void {
commandLoaderCache.set(cacheKey, commands)
}
export function deleteCachedCommands(cacheKey: string): void {
commandLoaderCache.delete(cacheKey)
}
export function clearCommandLoaderCache(): void {
commandLoaderCache.clear()
}
@@ -1,9 +1,10 @@
import { execFileSync } from "node:child_process"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { promises as fs } from "node:fs"
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
import * as loader from "./loader"
const TEST_DIR = join(tmpdir(), `claude-code-command-loader-${Date.now()}`)
@@ -16,19 +17,41 @@ function writeCommand(directory: string, name: string, description: string): voi
}
describe("claude-code command loader", () => {
let originalClaudeConfigDir: string | undefined
let originalOpencodeConfigDir: string | undefined
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const claudeConfigDir = join(TEST_DIR, "claude-config")
const opencodeConfigDir = join(TEST_DIR, "opencode-config")
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
if ("clearCommandLoaderCache" in loader && typeof loader.clearCommandLoaderCache === "function") {
loader.clearCommandLoaderCache()
}
})
afterEach(() => {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
if (originalOpencodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
}
if ("clearCommandLoaderCache" in loader && typeof loader.clearCommandLoaderCache === "function") {
loader.clearCommandLoaderCache()
}
rmSync(TEST_DIR, { recursive: true, force: true })
})
@@ -39,7 +62,7 @@ describe("claude-code command loader", () => {
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
// when
const commands = await loadOpencodeProjectCommands(childDir)
const commands = await loader.loadOpencodeProjectCommands(childDir)
// then
expect(commands.ancestor?.description).toBe("(opencode-project) Ancestor command")
@@ -50,7 +73,7 @@ describe("claude-code command loader", () => {
writeCommand(join(TEST_DIR, ".opencode", "command"), "singular", "Singular command")
// when
const commands = await loadOpencodeProjectCommands(TEST_DIR)
const commands = await loader.loadOpencodeProjectCommands(TEST_DIR)
// then
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
@@ -66,7 +89,7 @@ describe("claude-code command loader", () => {
writeCommand(projectDir, "duplicate", "Nearest command")
// when
const commands = await loadOpencodeProjectCommands(childDir)
const commands = await loader.loadOpencodeProjectCommands(childDir)
// then
expect(commands.duplicate?.description).toBe("(opencode-project) Nearest command")
@@ -79,7 +102,7 @@ describe("claude-code command loader", () => {
writeCommand(join(opencodeConfigDir, "commands"), "global-plural", "Global plural command")
// when
const commands = await loadOpencodeGlobalCommands()
const commands = await loader.loadOpencodeGlobalCommands()
// then
expect(commands["global-plural"]?.description).toBe("(opencode) Global plural command")
@@ -94,7 +117,7 @@ describe("claude-code command loader", () => {
writeCommand(join(profileConfigDir, "commands"), "duplicate-global", "Profile global command")
// when
const commands = await loadOpencodeGlobalCommands()
const commands = await loader.loadOpencodeGlobalCommands()
// then
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
@@ -114,7 +137,7 @@ describe("claude-code command loader", () => {
writeCommand(join(TEST_DIR, ".opencode", "commands"), "outside", "Outside command")
// when
const commands = await loadOpencodeProjectCommands(nestedDirectory)
const commands = await loader.loadOpencodeProjectCommands(nestedDirectory)
// then
expect(commands["deploy/staging"]?.description).toBe("(opencode-project) Deploy staging")
@@ -122,4 +145,38 @@ describe("claude-code command loader", () => {
expect(commands.outside).toBeUndefined()
expect(commands["deploy:staging"]).toBeUndefined()
})
it("#given commands nested under an excluded basename #when loadProjectCommands is called #then it skips the excluded directory contents", async () => {
// given
writeCommand(join(TEST_DIR, ".claude", "commands"), "real", "Real command")
writeCommand(
join(TEST_DIR, ".claude", "commands", "node_modules"),
"fake",
"Fake command",
)
// when
const commands = await loader.loadProjectCommands(TEST_DIR)
// then
expect(commands.real?.description).toBe("(project) Real command")
expect(commands.fake).toBeUndefined()
})
it("#given a previously loaded directory #when loadAllCommands is called twice #then the second call reuses the cached result without readdir calls", async () => {
// given
writeCommand(join(TEST_DIR, ".claude", "commands"), "cached", "Cached command")
const readdirSpy = spyOn(fs, "readdir")
// when
const firstCommands = await loader.loadAllCommands(TEST_DIR)
const firstReaddirCount = readdirSpy.mock.calls.length
const secondCommands = await loader.loadAllCommands(TEST_DIR)
// then
expect(firstCommands.cached?.description).toBe("(project) Cached command")
expect(secondCommands).toEqual(firstCommands)
expect(firstReaddirCount).toBeGreaterThan(0)
expect(readdirSpy.mock.calls.length).toBe(firstReaddirCount)
})
})
@@ -4,13 +4,23 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import {
EXCLUDED_DIRS,
findProjectOpencodeCommandDirs,
getClaudeConfigDir,
getOpenCodeCommandDirs,
} from "../../shared"
import { log } from "../../shared/logger"
import {
clearCommandLoaderCache,
deleteCachedCommands,
getCachedCommands,
getCommandLoaderCacheKey,
setCachedCommands,
} from "./loader-cache"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
export { clearCommandLoaderCache }
async function loadCommandsFromDir(
commandsDir: string,
scope: CommandScope,
@@ -48,6 +58,7 @@ async function loadCommandsFromDir(
for (const entry of entries) {
if (entry.isDirectory()) {
if (EXCLUDED_DIRS.has(entry.name)) continue
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}/${entry.name}` : entry.name
@@ -159,11 +170,26 @@ export async function loadOpencodeProjectCommands(directory?: string): Promise<R
}
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
const [user, project, global, projectOpencode] = await Promise.all([
const cacheKey = await getCommandLoaderCacheKey(directory)
const cachedCommands = getCachedCommands(cacheKey)
if (cachedCommands) {
return cachedCommands
}
const loadCommandsPromise = Promise.all([
loadUserCommands(),
loadProjectCommands(directory),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(directory),
])
return { ...projectOpencode, ...global, ...project, ...user }
.then(([user, project, global, projectOpencode]) => {
return { ...projectOpencode, ...global, ...project, ...user }
})
.catch((error) => {
deleteCachedCommands(cacheKey)
throw error
})
setCachedCommands(cacheKey, loadCommandsPromise)
return loadCommandsPromise
}
@@ -0,0 +1,78 @@
# src/features/claude-code-mcp-loader/ — Tier 2 MCP Loader (.mcp.json)
**Generated:** 2026-04-18
## OVERVIEW
11 files. Loads `.mcp.json` files from project/user scopes and expands `${VAR}` env vars. Feeds Tier 2 of the 3-tier MCP system into `mcp-config-handler.ts` during Phase 5 of config loading.
## WHY IT EXISTS
Claude Code ecosystem ships MCPs via `.mcp.json` files with `${VAR}` env var placeholders. OmO consumes these unchanged so existing Claude Code MCP configs work.
## LOAD PIPELINE
```
loadMcpConfigs(ctx)
→ scope-filter.ts: discover .mcp.json at project + user scopes
→ loader.ts: parse JSON
→ env-expander.ts: replace ${VAR} with process.env[VAR]
→ transformer.ts: map Claude Code format → OpenCode McpLocal / McpRemote shape
→ return LoadedMcpServer[]
```
## MCP FORMAT
```jsonc
// .mcp.json
{
"mcpServers": {
"my-stdio": {
"type": "stdio",
"command": "node",
"args": ["server.js"],
"env": {
"API_KEY": "${MY_API_KEY}"
}
},
"my-http": {
"type": "http", // "sse" legacy → mapped to http
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${MY_TOKEN}"
}
}
}
}
```
## KEY FILES
| File | Purpose |
|------|---------|
| `index.ts` | Barrel: `loadMcpConfigs`, types |
| `loader.ts` | `loadMcpConfigs()` main entry |
| `types.ts` | `ClaudeCodeMcpServer`, `LoadedMcpServer`, `McpScope` |
| `env-expander.ts` | `expandEnvVarsInObject()` — recursive `${VAR}` substitution |
| `transformer.ts` | Claude Code format → OpenCode `Mcp` shape |
| `scope-filter.ts` | Project vs user scope precedence |
## THREE-TIER MCP CONTEXT
| Tier | Loader | Scope |
|------|--------|-------|
| 1. Built-in | `src/mcp/` `createBuiltinMcps()` | Global, 3 remote HTTP MCPs |
| 2. **Claude Code** | **This module** | **From `.mcp.json`, project + user** |
| 3. Skill-embedded | `src/features/skill-mcp-manager/` | Per-session, from SKILL.md YAML |
## SECURITY
- **Env var allowlist**: `mcp_env_allowlist` config restricts which env vars can be expanded
- **No shell execution**: `${VAR}` is string replacement only, not shell `$()`
- **Secrets redaction**: `env-cleaner.ts` (in skill-mcp-manager) filters known secret patterns from logs
## RELATED
- Phase 5 integration: `src/plugin-handlers/mcp-config-handler.ts`
- Skill-embedded MCPs (Tier 3): `src/features/skill-mcp-manager/`
- Built-in MCPs (Tier 1): `src/mcp/`
@@ -0,0 +1,78 @@
# src/features/claude-code-plugin-loader/ — Unified Claude Code Plugin Loader
**Generated:** 2026-04-18
## OVERVIEW
16 files. Full Claude Code plugin compatibility layer. Discovers and loads ALL plugin components (commands, agents, skills, hooks, MCP servers, LSP servers) from `.opencode/plugins/` and `~/.claude/plugins/`.
## WHY IT EXISTS
Claude Code plugins ship commands/agents/skills as separate files with `plugin.json` manifest. OmO uses this loader to ingest them into its own registry so existing Claude Code plugins work unchanged under OmO.
## LOAD PIPELINE
```
loadAllPluginComponents(ctx)
→ discoverPlugins() # scan .opencode/plugins + ~/.claude/plugins
→ readPluginManifest(plugin.json) # parse name/version/commands/agents/skills/hooks/mcpServers
→ loadPluginCommands()
→ loadPluginAgents()
→ loadPluginSkills()
→ loadPluginHooks() # register hook handlers
→ loadPluginMcpServers() # feed into mcp-config-handler (tier 2)
→ loadPluginLspServers()
→ return LoadedPluginBundle
```
Called from `src/plugin-handlers/plugin-components-loader.ts` during Phase 2 of config handler (10s timeout with error isolation — one broken plugin does not sink the plugin load).
## KEY FILES
| File | Purpose |
|------|---------|
| `index.ts` | Barrel: `loadAllPluginComponents`, `PluginManifest`, `ClaudeSettings` types |
| `plugin-discovery.ts` | Find plugin directories across scopes |
| `plugin-manifest-parser.ts` | Parse `plugin.json` with Zod validation |
| `command-loader.ts` | Load commands from `commands/` or `COMMANDS.md` |
| `agent-loader.ts` | Load agents from `agents/` or `AGENTS.md` frontmatter |
| `skill-loader.ts` | Load skills from `skills/` or `SKILL.md` |
| `hook-loader.ts` | Load hooks config from `hooks/` or manifest |
| `mcp-loader.ts` | Extract MCP server configs |
| `lsp-loader.ts` | Extract LSP server configs |
| `settings-loader.ts` | Parse Claude Code `settings.json` |
## PLUGIN MANIFEST (plugin.json)
```jsonc
{
"name": "my-plugin",
"version": "1.0.0",
"description": "...",
"commands": ["./commands"], // or string[] of paths
"agents": ["./agents"],
"skills": ["./skills"],
"hooks": "./hooks/config.json",
"mcpServers": "./.mcp.json",
"lspServers": "./lsp"
}
```
## SCOPES
| Scope | Path | Priority |
|-------|------|----------|
| `project` | `.opencode/plugins/` | Highest |
| `local` | `~/.opencode/plugins/` | Medium |
| `user` | `~/.claude/plugins/` | Medium |
| `managed` | Built-in | Lowest |
## ERROR ISOLATION
Each plugin loads in isolation — if one fails (bad manifest, missing file, syntax error), others still load. Errors surface as warnings in `bunx oh-my-opencode doctor`.
## RELATED
- Phase 2 loader: `src/plugin-handlers/plugin-components-loader.ts`
- Tier 2 MCP integration: `src/features/claude-code-mcp-loader/`
- Claude Code compat hooks: `src/hooks/claude-code-hooks/`
@@ -3,12 +3,19 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content"
import {
clearSkillCache,
resolveSkillContent,
resolveMultipleSkills,
resolveSkillContentAsync,
resolveMultipleSkillsAsync,
} from "./skill-content"
let originalEnv: Record<string, string | undefined>
let testConfigDir: string
beforeEach(() => {
clearSkillCache()
originalEnv = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
@@ -20,6 +27,7 @@ beforeEach(() => {
})
afterEach(() => {
clearSkillCache()
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
@@ -203,7 +203,7 @@ describe("TaskToastManager", () => {
description: "Task with inherited model",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "cliproxy/claude-opus-4-6", type: "inherited" as const },
modelInfo: { model: "cliproxy/claude-opus-4-7", type: "inherited" as const },
}
// when - addTask is called
@@ -213,7 +213,7 @@ describe("TaskToastManager", () => {
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("[FALLBACK]")
expect(call.body.message).toContain("cliproxy/claude-opus-4-6")
expect(call.body.message).toContain("cliproxy/claude-opus-4-7")
expect(call.body.message).toContain("(inherited from parent)")
})
+2
View File
@@ -0,0 +1,2 @@
export * from "./types"
export * from "./team-worktree"
@@ -0,0 +1 @@
export { canVisualize, createTeamLayout, removeTeamLayout } from "./layout"
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, mock, test } from "bun:test"
type LayoutModule = typeof import("./layout")
const spawnMock = mock(() => ({
exited: Promise.resolve(0),
stdout: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("%1\n")); controller.close() } }),
stderr: new ReadableStream({ start(controller) { controller.close() } }),
}))
const layoutSpecifier = import.meta.resolve("./layout")
const spawnProcessSpecifier = import.meta.resolve("../../../shared/tmux/tmux-utils/spawn-process")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const sharedSpecifier = import.meta.resolve("../../../shared")
function registerModuleMocks(): void {
mock.module(spawnProcessSpecifier, () => ({ spawn: spawnMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: mock(() => Promise.resolve("tmux")) }))
mock.module(sharedSpecifier, () => ({ log: mock(() => undefined) }))
}
async function loadLayoutModule(): Promise<LayoutModule> {
const module = await import(`${layoutSpecifier}?test=${crypto.randomUUID()}`)
return module as LayoutModule
}
describe("team-layout-tmux", () => {
beforeEach(() => {
registerModuleMocks()
spawnMock.mockClear()
process.env.TMUX = "/tmp/tmux-1"
})
test("returns null and makes no tmux calls when visualization unavailable", async () => {
// given
delete process.env.TMUX
const { createTeamLayout, canVisualize } = await loadLayoutModule()
// when
const result = await createTeamLayout("run-1", [], {} as never)
// then
expect(canVisualize()).toBe(false)
expect(result).toBeNull()
expect(spawnMock).toHaveBeenCalledTimes(0)
})
test("creates focus and grid windows", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
const members = [
{ name: "lead", sessionId: "s1", color: "red" },
{ name: "m2", sessionId: "s2" },
{ name: "m3", sessionId: "s3" },
]
// when
await createTeamLayout("run-2", members, {} as never)
// then
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("new-session")
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("new-window")
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("split-window")
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("select-layout")
expect(spawnMock.mock.calls.flatMap((call) => call[0] as Array<string>)).toContain("select-pane")
})
test("returns null when tmux command fails", async () => {
// given
const { createTeamLayout } = await loadLayoutModule()
spawnMock.mockImplementationOnce(() => ({
exited: Promise.resolve(1),
stdout: new ReadableStream({ start(controller) { controller.close() } }),
stderr: new ReadableStream({ start(controller) { controller.close() } }),
}))
// when
const result = await createTeamLayout("run-3", [{ name: "lead", sessionId: "s1" }], {} as never)
// then
expect(result).toBeNull()
})
test("cleans up the tmux session", async () => {
// given
const { removeTeamLayout } = await loadLayoutModule()
// when
await removeTeamLayout("run-4", {} as never)
// then
expect(spawnMock.mock.calls.some((call) => (call[0] as Array<string>).includes("kill-session"))).toBe(true)
})
})
@@ -0,0 +1,120 @@
import { spawn } from "../../../shared/tmux/tmux-utils/spawn-process"
import { log } from "../../../shared"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
type TeamLayoutMember = { name: string; sessionId: string; color?: string }
type TeamLayoutResult = {
focusWindowId: string
gridWindowId: string
panesByMember: Record<string, string>
}
export function canVisualize(): boolean {
return process.env.TMUX !== undefined
}
async function runTmux(tmuxPath: string, args: Array<string>): Promise<{ success: boolean; output: string }> {
const proc = spawn([tmuxPath, ...args], { stdout: "pipe", stderr: "pipe" })
const outputPromise = new Response(proc.stdout).text()
const exitCode = await proc.exited
const output = await outputPromise
if (exitCode !== 0) {
return { success: false, output: output.trim() }
}
return { success: true, output: output.trim() }
}
async function createWindow(
tmuxPath: string,
sessionName: string,
windowName: string,
layout: "main-vertical" | "tiled",
members: Array<TeamLayoutMember>,
): Promise<{ windowId: string; panesByMember: Record<string, string> } | null> {
const base = await runTmux(tmuxPath, ["new-window", "-d", "-P", "-F", "#{window_id}", "-t", sessionName, "-n", windowName])
if (!base.success || !base.output) return null
const panesByMember: Record<string, string> = {}
const [lead, ...rest] = members
if (!lead) return null
const leadPane = await runTmux(tmuxPath, ["list-panes", "-t", `${sessionName}:${base.output}`, "-F", "#{pane_id}"])
if (!leadPane.success || !leadPane.output) return null
panesByMember[lead.name] = leadPane.output.split("\n")[0] ?? ""
for (const member of rest) {
const split = await runTmux(tmuxPath, ["split-window", "-d", "-P", "-F", "#{pane_id}", "-t", panesByMember[lead.name] ?? base.output, "sh", "-c", "cat >/dev/null"])
if (!split.success || !split.output) return null
panesByMember[member.name] = split.output
}
const layoutResult = await runTmux(tmuxPath, ["select-layout", "-t", `${sessionName}:${base.output}`, layout])
if (!layoutResult.success) return null
for (const member of members) {
const paneId = panesByMember[member.name]
if (!paneId) return null
const label = member.color ? `${member.name} ${member.color}` : member.name
const titleResult = await runTmux(tmuxPath, ["select-pane", "-t", paneId, "-T", label])
if (!titleResult.success) return null
await runTmux(tmuxPath, ["set-option", "-t", paneId, "pane-border-status", "top"])
await runTmux(tmuxPath, ["set-option", "-t", paneId, "pane-border-format", `#{pane_title} ${label}`])
await runTmux(tmuxPath, ["pipe-pane", "-I", "-t", paneId, "cat >/dev/null"])
}
return { windowId: base.output, panesByMember }
}
export async function createTeamLayout(
teamRunId: string,
members: Array<TeamLayoutMember>,
tmuxMgr: TmuxSessionManager,
): Promise<TeamLayoutResult | null> {
if (!canVisualize()) {
log("tmux visualization unavailable, skipping")
return null
}
try {
void tmuxMgr
const tmuxPath = await getTmuxPath()
if (!tmuxPath) {
log("tmux visualization unavailable, skipping")
return null
}
const sessionName = `omo-team-${teamRunId}`
const created = await runTmux(tmuxPath, ["new-session", "-d", "-s", sessionName, "-P", "-F", "#{window_id}"])
if (!created.success || !created.output) return null
const focus = await createWindow(tmuxPath, sessionName, "focus", "main-vertical", members)
const grid = await createWindow(tmuxPath, sessionName, "grid", "tiled", members)
if (!focus || !grid) return null
return {
focusWindowId: focus.windowId,
gridWindowId: grid.windowId,
panesByMember: focus.panesByMember,
}
} catch (error) {
log("tmux visualization unavailable, skipping", { error: String(error) })
return null
}
}
export async function removeTeamLayout(teamRunId: string, tmuxMgr: TmuxSessionManager): Promise<void> {
void tmuxMgr
if (!canVisualize()) return
try {
const tmuxPath = await getTmuxPath()
if (!tmuxPath) return
await runTmux(tmuxPath, ["kill-session", "-t", `omo-team-${teamRunId}`])
} catch {
return
}
}
@@ -0,0 +1,31 @@
/// <reference types="bun-types" />
import { afterAll, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { findOrphanWorktrees } from "./cleanup"
const temporaryDirectories: string[] = []
afterAll(async () => {
for (const directory of temporaryDirectories) {
await fs.rm(directory, { recursive: true, force: true })
}
})
test("given runtime mismatch when findOrphanWorktrees then returns orphan paths", async () => {
// given
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "team-worktree-orphans-"))
temporaryDirectories.push(baseDir)
await fs.mkdir(path.join(baseDir, "worktrees", "t1", "m1"), { recursive: true })
await fs.mkdir(path.join(baseDir, "runtime", "t1"), { recursive: true })
await fs.writeFile(path.join(baseDir, "runtime", "t1", "state.json"), JSON.stringify({ status: "deleted" }))
// when
const result = await findOrphanWorktrees(baseDir, {})
// then
expect(result).toEqual([path.join(baseDir, "worktrees", "t1", "m1")])
})

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