Consolidate duplicate patterns and simplify codebase (#1317)

* refactor(shared): unify binary downloader and session path storage

- Create binary-downloader.ts for common download/extract logic
- Create session-injected-paths.ts for unified path tracking
- Refactor comment-checker, ast-grep, grep downloaders to use shared util
- Consolidate directory injector types into shared module

* feat(shared): implement unified model resolution pipeline

- Create ModelResolutionPipeline for centralized model selection
- Refactor model-resolver to use pipeline
- Update delegate-task and config-handler to use unified logic
- Ensure consistent model resolution across all agent types

* refactor(agents): simplify agent utils and metadata management

- Extract helper functions for config merging and env context
- Register prompt metadata for all agents
- Simplify agent variant detection logic

* cleanup: inline utilities and remove unused exports

- Remove case-insensitive.ts (inline with native JS)
- Simplify opencode-version helpers
- Remove unused getModelLimit, createCompactionContextInjector exports
- Inline transcript entry creation in claude-code-hooks
- Update tests accordingly

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
YeonGyu-Kim
2026-01-31 15:46:14 +09:00
committed by GitHub
parent 4b5e38f8f8
commit 4a82ff40fb
31 changed files with 597 additions and 848 deletions

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.1.6",
"oh-my-opencode-darwin-x64": "3.1.6",
"oh-my-opencode-linux-arm64": "3.1.6",
"oh-my-opencode-linux-arm64-musl": "3.1.6",
"oh-my-opencode-linux-x64": "3.1.6",
"oh-my-opencode-linux-x64-musl": "3.1.6",
"oh-my-opencode-windows-x64": "3.1.6",
"oh-my-opencode-darwin-arm64": "3.1.10",
"oh-my-opencode-darwin-x64": "3.1.10",
"oh-my-opencode-linux-arm64": "3.1.10",
"oh-my-opencode-linux-arm64-musl": "3.1.10",
"oh-my-opencode-linux-x64": "3.1.10",
"oh-my-opencode-linux-x64-musl": "3.1.10",
"oh-my-opencode-windows-x64": "3.1.10",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.6", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KK+ptnkBigvDYbRtF/B5izEC4IoXDS8mAnRHWFBSCINhzQR2No6AtEcwijd6vKBPR+/r71ofq/8mTsIeb1PEVQ=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.10", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-6qsZQtrtBYZLufcXTTuUUMEG9PoG9Y98pX+HFVn2xHIEc6GpwR6i5xY8McFHmqPkC388tzybD556JhKqPX7Pnw=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.6", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UkPI/RUi7INarFasBUZ4Rous6RUQXsU2nr0V8KFJp+70END43D/96dDUwX+zmPtpDhD+DfWkejuwzqfkZJ2ZDQ=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.10", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-I1tQQbcpSBvLGXTO652mBqlyIpwYhYuIlSJmrSM33YRGBiaUuhMASnHQsms+E0eC3U/TOyqomU/4KPnbWyxs4w=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gvmvgh7WtTtcHiCbG7z43DOYfY/jrf2S6TX/jBMX2/e1AGkcLKwz30NjGhZxeK5SyzxRVypgfZZK1IuriRgbdA=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.10", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-r6Rm5Ru/WwcBKKuPIP0RreI0gnf+MYRV0mmzPBVhMZdPWSC/eTT3GdyqFDZ4cCN76n5aea0sa5PPW7iPF+Uw6Q=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-j3R76pmQ4HGVGFJUMMCeF/1lO3Jg7xFdpcBUKCeFh42N1jMgn1aeyxkAaJYB9RwCF/p6+P8B6gVDLCEDu2mxjA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.10", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UVo5OWO92DPIFhoEkw0tj8IcZyUKOG6NlFs1+tSExz7qrgkr0IloxpLslGMmdc895xxpljrr/FobYktLxyJbcg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VDdo0tHCOr5nm7ajd652u798nPNOLRSTcPOnVh6vIPddkZ+ujRke+enOKOw9Pd5e+4AkthqHBwFXNm2VFgnEKg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.10", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-3g99z2FweMzHSUYuzgU0E2H0kjVmtOhPZdavwVqcHQtLQ9NNhwfnIvj3yFBif+kGJphP9RDnByC1oA8Q26UrCg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-hBG/dhsr8PZelUlYsPBruSLnelB9ocB7H92I+S9svTpDVo67rAmXOoR04twKQ9TeCO4ShOa6hhMhbQnuI8fgNw=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.10", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-2HS9Ju0Cr433lMFJtu/7bShApOJywp+zmVCduQUBWFi3xbX1nm5sJwWDhw1Wx+VcqHEuJl/SQzWPE4vaqkEQng=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.6", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-c8Awp03p2DsbS0G589nzveRCeJPgJRJ0vQrha4ChRmmo31Qc5OSmJ5xuMaF8L4nM+/trbTgAQMFMtCMLgtC8IQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.10", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-QLncZJSlWmmcuXrAVKIH6a9Om1Ym6pkhG4hAxaD5K5aF1jw2QFsadjoT12VNq2WzQb+Pg5Y6IWvoow0ZR0aEvw=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -1,8 +1,14 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
const MODE: AgentMode = "primary"
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility",
cost: "EXPENSIVE",
promptAlias: "Sisyphus",
triggers: [],
}
import type { AvailableAgent, AvailableTool, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
buildKeyTriggersSection,

View File

@@ -6,11 +6,11 @@ import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import { createMetisAgent } from "./metis"
import { createAtlasAgent } from "./atlas"
import { createMomusAgent } from "./momus"
import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache, isModelAvailable } from "../shared"
import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills"
@@ -41,6 +41,9 @@ const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
metis: metisPromptMetadata,
momus: momusPromptMetadata,
atlas: atlasPromptMetadata,
}
function isFactory(source: AgentSource): source is AgentFactory {
@@ -147,6 +150,45 @@ function applyCategoryOverride(
return result as AgentConfig
}
function applyModelResolution(input: {
uiSelectedModel?: string
userModel?: string
requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] }
availableModels: Set<string>
systemDefaultModel?: string
}) {
const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input
return resolveModelPipeline({
intent: { uiSelectedModel, userModel },
constraints: { availableModels },
policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel },
})
}
function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig {
if (!directory || !config.prompt) return config
const envContext = createEnvContext()
return { ...config, prompt: config.prompt + envContext }
}
function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
result = applyCategoryOverride(result, overrideCategory, mergedCategories)
}
if (override) {
result = mergeAgentConfig(result, override)
}
return result
}
function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
@@ -223,9 +265,10 @@ export async function createBuiltinAgents(
if (agentName === "sisyphus") continue
if (agentName === "atlas") continue
if (includesCaseInsensitive(disabledAgents, agentName)) continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = findCaseInsensitive(agentOverrides, agentName)
const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
@@ -237,10 +280,10 @@ export async function createBuiltinAgents(
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = resolveModelWithFallback({
const resolution = applyModelResolution({
uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined,
userModel: override?.model,
fallbackChain: requirement?.fallbackChain,
requirement,
availableModels,
systemDefaultModel,
})
@@ -260,15 +303,11 @@ export async function createBuiltinAgents(
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
}
if (agentName === "librarian" && directory && config.prompt) {
const envContext = createEnvContext()
config = { ...config, prompt: config.prompt + envContext }
if (agentName === "librarian") {
config = applyEnvironmentContext(config, directory)
}
// Direct override properties take highest priority
if (override) {
config = mergeAgentConfig(config, override)
}
config = applyOverrides(config, override, mergedCategories)
result[name] = config
@@ -286,10 +325,10 @@ export async function createBuiltinAgents(
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const sisyphusResolution = resolveModelWithFallback({
const sisyphusResolution = applyModelResolution({
uiSelectedModel,
userModel: sisyphusOverride?.model,
fallbackChain: sisyphusRequirement?.fallbackChain,
requirement: sisyphusRequirement,
availableModels,
systemDefaultModel,
})
@@ -309,19 +348,8 @@ export async function createBuiltinAgents(
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
const sisOverrideCategory = (sisyphusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (sisOverrideCategory) {
sisyphusConfig = applyCategoryOverride(sisyphusConfig, sisOverrideCategory, mergedCategories)
}
if (directory && sisyphusConfig.prompt) {
const envContext = createEnvContext()
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
}
if (sisyphusOverride) {
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
result["sisyphus"] = sisyphusConfig
}
@@ -331,10 +359,10 @@ export async function createBuiltinAgents(
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = resolveModelWithFallback({
const atlasResolution = applyModelResolution({
// NOTE: Atlas does NOT use uiSelectedModel - respects its own fallbackChain (k2p5 primary)
userModel: orchestratorOverride?.model,
fallbackChain: atlasRequirement?.fallbackChain,
requirement: atlasRequirement,
availableModels,
systemDefaultModel,
})
@@ -353,14 +381,7 @@ export async function createBuiltinAgents(
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
const atlasOverrideCategory = (orchestratorOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (atlasOverrideCategory) {
orchestratorConfig = applyCategoryOverride(orchestratorConfig, atlasOverrideCategory, mergedCategories)
}
if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
result["atlas"] = orchestratorConfig
}

View File

@@ -24,7 +24,7 @@ import {
type PreCompactContext,
} from "./pre-compact"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import { appendTranscriptEntry, getTranscriptPath } from "./transcript"
import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import type { ContextCollector } from "../../features/context-injector"
@@ -92,7 +92,11 @@ export function createClaudeCodeHooksHook(
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
const prompt = textParts.map((p) => p.text ?? "").join("\n")
recordUserMessage(input.sessionID, prompt)
appendTranscriptEntry(input.sessionID, {
type: "user",
timestamp: new Date().toISOString(),
content: prompt,
})
const messageParts: MessagePart[] = textParts.map((p) => ({
type: p.type as "text",
@@ -198,7 +202,12 @@ export function createClaudeCodeHooksHook(
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
appendTranscriptEntry(input.sessionID, {
type: "tool_use",
timestamp: new Date().toISOString(),
tool_name: input.tool,
tool_input: output.args as Record<string, unknown>,
})
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
@@ -253,7 +262,13 @@ export function createClaudeCodeHooksHook(
const metadata = output.metadata as Record<string, unknown> | undefined
const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
const toolOutput = hasMetadata ? metadata : { output: output.output }
recordToolResult(input.sessionID, input.tool, cachedInput, toolOutput)
appendTranscriptEntry(input.sessionID, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: input.tool,
tool_input: cachedInput,
tool_output: toolOutput,
})
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = {

View File

@@ -28,56 +28,6 @@ export function appendTranscriptEntry(
appendFileSync(path, line)
}
export function recordToolUse(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_use",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
})
}
export function recordToolResult(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>,
toolOutput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
tool_output: toolOutput,
})
}
export function recordUserMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "user",
timestamp: new Date().toISOString(),
content,
})
}
export function recordAssistantMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "assistant",
timestamp: new Date().toISOString(),
content,
})
}
// ============================================================================
// Claude Code Compatible Transcript Builder (PORT FROM DISABLED)
// ============================================================================

View File

@@ -1,9 +1,16 @@
import { spawn } from "bun"
import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs"
import { existsSync, appendFileSync } from "fs"
import { join } from "path"
import { homedir, tmpdir } from "os"
import { createRequire } from "module"
import { extractZip } from "../../shared"
import {
cleanupArchive,
downloadArchive,
ensureCacheDir,
ensureExecutable,
extractTarGz,
extractZipArchive,
getCachedBinaryPath as getCachedBinaryPathShared,
} from "../../shared/binary-downloader"
import { log } from "../../shared/logger"
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
@@ -60,8 +67,7 @@ export function getBinaryName(): string {
* Get the cached binary path if it exists.
*/
export function getCachedBinaryPath(): string | null {
const binaryPath = join(getCacheDir(), getBinaryName())
return existsSync(binaryPath) ? binaryPath : null
return getCachedBinaryPathShared(getCacheDir(), getBinaryName())
}
/**
@@ -78,27 +84,6 @@ function getPackageVersion(): string {
}
}
/**
* Extract tar.gz archive using system tar command.
*/
async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
debugLog("Extracting tar.gz:", archivePath, "to", destDir)
const proc = spawn(["tar", "-xzf", archivePath, "-C", destDir], {
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`)
}
}
/**
* Download the comment-checker binary from GitHub Releases.
* Returns the path to the downloaded binary, or null on failure.
@@ -132,39 +117,26 @@ export async function downloadCommentChecker(): Promise<string | null> {
try {
// Ensure cache directory exists
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true })
}
// Download with fetch() - Bun handles redirects automatically
const response = await fetch(downloadUrl, { redirect: "follow" })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
ensureCacheDir(cacheDir)
const archivePath = join(cacheDir, assetName)
const arrayBuffer = await response.arrayBuffer()
await Bun.write(archivePath, arrayBuffer)
await downloadArchive(downloadUrl, archivePath)
debugLog(`Downloaded archive to: ${archivePath}`)
// Extract based on file type
if (ext === "tar.gz") {
debugLog("Extracting tar.gz:", archivePath, "to", cacheDir)
await extractTarGz(archivePath, cacheDir)
} else {
await extractZip(archivePath, cacheDir)
await extractZipArchive(archivePath, cacheDir)
}
// Clean up archive
if (existsSync(archivePath)) {
unlinkSync(archivePath)
}
cleanupArchive(archivePath)
// Set execute permission on Unix
if (process.platform !== "win32" && existsSync(binaryPath)) {
chmodSync(binaryPath, 0o755)
}
ensureExecutable(binaryPath)
debugLog(`Successfully downloaded binary to: ${binaryPath}`)
log(`[oh-my-opencode] comment-checker binary ready.`)

View File

@@ -1,48 +1,8 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { AGENTS_INJECTOR_STORAGE } from "./constants";
import type { InjectedPathsData } from "./types";
import { createInjectedPathsStorage } from "../../shared/session-injected-paths";
function getStoragePath(sessionID: string): string {
return join(AGENTS_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedPaths(sessionID: string): Set<string> {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
}
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
if (!existsSync(AGENTS_INJECTOR_STORAGE)) {
mkdirSync(AGENTS_INJECTOR_STORAGE, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
}
export function clearInjectedPaths(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}
export const {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
} = createInjectedPathsStorage(AGENTS_INJECTOR_STORAGE);

View File

@@ -1,5 +0,0 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}

View File

@@ -1,48 +1,8 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { README_INJECTOR_STORAGE } from "./constants";
import type { InjectedPathsData } from "./types";
import { createInjectedPathsStorage } from "../../shared/session-injected-paths";
function getStoragePath(sessionID: string): string {
return join(README_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedPaths(sessionID: string): Set<string> {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
}
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
if (!existsSync(README_INJECTOR_STORAGE)) {
mkdirSync(README_INJECTOR_STORAGE, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
}
export function clearInjectedPaths(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}
export const {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
} = createInjectedPathsStorage(README_INJECTOR_STORAGE);

View File

@@ -1,5 +0,0 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}

View File

@@ -2,24 +2,6 @@ import { spawn } from "bun"
type Platform = "darwin" | "linux" | "win32" | "unsupported"
let notifySendPath: string | null = null
let notifySendPromise: Promise<string | null> | null = null
let osascriptPath: string | null = null
let osascriptPromise: Promise<string | null> | null = null
let powershellPath: string | null = null
let powershellPromise: Promise<string | null> | null = null
let afplayPath: string | null = null
let afplayPromise: Promise<string | null> | null = null
let paplayPath: string | null = null
let paplayPromise: Promise<string | null> | null = null
let aplayPath: string | null = null
let aplayPromise: Promise<string | null> | null = null
async function findCommand(commandName: string): Promise<string | null> {
const isWindows = process.platform === "win32"
const cmd = isWindows ? "where" : "which"
@@ -48,83 +30,30 @@ async function findCommand(commandName: string): Promise<string | null> {
}
}
export async function getNotifySendPath(): Promise<string | null> {
if (notifySendPath !== null) return notifySendPath
if (notifySendPromise) return notifySendPromise
function createCommandFinder(commandName: string): () => Promise<string | null> {
let cachedPath: string | null = null
let pending: Promise<string | null> | null = null
notifySendPromise = (async () => {
const path = await findCommand("notify-send")
notifySendPath = path
return path
})()
return async () => {
if (cachedPath !== null) return cachedPath
if (pending) return pending
return notifySendPromise
pending = (async () => {
const path = await findCommand(commandName)
cachedPath = path
return path
})()
return pending
}
}
export async function getOsascriptPath(): Promise<string | null> {
if (osascriptPath !== null) return osascriptPath
if (osascriptPromise) return osascriptPromise
osascriptPromise = (async () => {
const path = await findCommand("osascript")
osascriptPath = path
return path
})()
return osascriptPromise
}
export async function getPowershellPath(): Promise<string | null> {
if (powershellPath !== null) return powershellPath
if (powershellPromise) return powershellPromise
powershellPromise = (async () => {
const path = await findCommand("powershell")
powershellPath = path
return path
})()
return powershellPromise
}
export async function getAfplayPath(): Promise<string | null> {
if (afplayPath !== null) return afplayPath
if (afplayPromise) return afplayPromise
afplayPromise = (async () => {
const path = await findCommand("afplay")
afplayPath = path
return path
})()
return afplayPromise
}
export async function getPaplayPath(): Promise<string | null> {
if (paplayPath !== null) return paplayPath
if (paplayPromise) return paplayPromise
paplayPromise = (async () => {
const path = await findCommand("paplay")
paplayPath = path
return path
})()
return paplayPromise
}
export async function getAplayPath(): Promise<string | null> {
if (aplayPath !== null) return aplayPath
if (aplayPromise) return aplayPromise
aplayPromise = (async () => {
const path = await findCommand("aplay")
aplayPath = path
return path
})()
return aplayPromise
}
export const getNotifySendPath = createCommandFinder("notify-send")
export const getOsascriptPath = createCommandFinder("osascript")
export const getPowershellPath = createCommandFinder("powershell")
export const getAfplayPath = createCommandFinder("afplay")
export const getPaplayPath = createCommandFinder("paplay")
export const getAplayPath = createCommandFinder("aplay")
export function startBackgroundCheck(platform: Platform): void {
if (platform === "darwin") {

View File

@@ -1,6 +1,4 @@
import { describe, expect, it } from "bun:test"
import { includesCaseInsensitive } from "./shared"
/**
* Tests for conditional tool registration logic in index.ts
*
@@ -13,8 +11,10 @@ describe("look_at tool conditional registration", () => {
// #when checking if agent is enabled
// #then should return false (disabled)
it("returns false when multimodal-looker is disabled (exact case)", () => {
const disabledAgents = ["multimodal-looker"]
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
const disabledAgents: string[] = ["multimodal-looker"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(false)
})
@@ -22,8 +22,10 @@ describe("look_at tool conditional registration", () => {
// #when checking if agent is enabled
// #then should return false (case-insensitive match)
it("returns false when multimodal-looker is disabled (case-insensitive)", () => {
const disabledAgents = ["Multimodal-Looker"]
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
const disabledAgents: string[] = ["Multimodal-Looker"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(false)
})
@@ -31,8 +33,10 @@ describe("look_at tool conditional registration", () => {
// #when checking if agent is enabled
// #then should return true (enabled)
it("returns true when multimodal-looker is not disabled", () => {
const disabledAgents = ["oracle", "librarian"]
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
const disabledAgents: string[] = ["oracle", "librarian"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(true)
})
@@ -41,7 +45,9 @@ describe("look_at tool conditional registration", () => {
// #then should return true (enabled by default)
it("returns true when disabled_agents is empty", () => {
const disabledAgents: string[] = []
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(true)
})
@@ -49,8 +55,11 @@ describe("look_at tool conditional registration", () => {
// #when checking if agent is enabled
// #then should return true (enabled by default)
it("returns true when disabled_agents is undefined (fallback to empty)", () => {
const disabledAgents = undefined
const isEnabled = !includesCaseInsensitive(disabledAgents ?? [], "multimodal-looker")
const disabledAgents: string[] | undefined = undefined
const list: string[] = disabledAgents ?? []
const isEnabled = !list.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(true)
})
})

View File

@@ -12,8 +12,6 @@ import {
createThinkModeHook,
createClaudeCodeHooksHook,
createAnthropicContextWindowLimitRecoveryHook,
createCompactionContextInjector,
createRulesInjectorHook,
createBackgroundNotificationHook,
createAutoUpdateCheckerHook,
@@ -80,9 +78,9 @@ import { initTaskToastManager } from "./features/task-toast-manager";
import { TmuxSessionManager } from "./features/tmux-subagent";
import { clearBoulderState } from "./features/boulder-state";
import { type HookName } from "./config";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createModelCacheState } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
@@ -176,9 +174,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
experimental: pluginConfig.experimental,
})
: null;
const compactionContextInjector = isHookEnabled("compaction-context-injector")
? createCompactionContextInjector()
: undefined;
const rulesInjector = isHookEnabled("rules-injector")
? createRulesInjectorHook(ctx)
: null;
@@ -303,9 +298,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
const isMultimodalLookerEnabled = !includesCaseInsensitive(
pluginConfig.disabled_agents ?? [],
"multimodal-looker"
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
(agent) => agent.toLowerCase() === "multimodal-looker"
);
const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null;
const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright";
@@ -626,9 +620,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (input.tool === "task") {
const args = output.args as Record<string, unknown>;
const subagentType = args.subagent_type as string;
const isExploreOrLibrarian = includesCaseInsensitive(
["explore", "librarian"],
subagentType ?? ""
const isExploreOrLibrarian = ["explore", "librarian"].some(
(name) => name.toLowerCase() === (subagentType ?? "").toLowerCase()
);
args.tools = {

View File

@@ -25,11 +25,10 @@ import { loadMcpConfigs } from "../features/claude-code-mcp-loader";
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
import { createBuiltinMcps } from "../mcp";
import type { OhMyOpenCodeConfig } from "../config";
import { log, fetchAvailableModels, readConnectedProvidersCache } from "../shared";
import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline } from "../shared";
import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir";
import { migrateAgentConfig } from "../shared/permission-compat";
import { AGENT_NAME_MAP } from "../shared/migration";
import { resolveModelWithFallback } from "../shared/model-resolver";
import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements";
import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus-prompt";
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants";
@@ -259,12 +258,16 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
connectedProviders: connectedProviders ?? undefined,
});
const modelResolution = resolveModelWithFallback({
uiSelectedModel: currentModel,
userModel: prometheusOverride?.model ?? categoryConfig?.model,
fallbackChain: prometheusRequirement?.fallbackChain,
availableModels,
systemDefaultModel: undefined,
const modelResolution = resolveModelPipeline({
intent: {
uiSelectedModel: currentModel,
userModel: prometheusOverride?.model ?? categoryConfig?.model,
},
constraints: { availableModels },
policy: {
fallbackChain: prometheusRequirement?.fallbackChain,
systemDefaultModel: undefined,
},
});
const resolvedModel = modelResolution?.model;
const resolvedVariant = modelResolution?.variant;

View File

@@ -4,8 +4,6 @@
* true = tool allowed, false = tool denied.
*/
import { findCaseInsensitive } from "./case-insensitive"
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
write: false,
edit: false,
@@ -37,10 +35,13 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
}
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
return findCaseInsensitive(AGENT_RESTRICTIONS, agentName) ?? {}
return AGENT_RESTRICTIONS[agentName]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const restrictions = findCaseInsensitive(AGENT_RESTRICTIONS, agentName)
const restrictions = AGENT_RESTRICTIONS[agentName]
?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
return restrictions !== undefined && Object.keys(restrictions).length > 0
}

View File

@@ -1,5 +1,4 @@
import type { OhMyOpenCodeConfig } from "../config"
import { findCaseInsensitive } from "./case-insensitive"
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements"
export function resolveAgentVariant(
@@ -13,7 +12,10 @@ export function resolveAgentVariant(
const agentOverrides = config.agents as
| Record<string, { variant?: string; category?: string }>
| undefined
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
const agentOverride = agentOverrides
? agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
: undefined
if (!agentOverride) {
return undefined
}
@@ -43,7 +45,10 @@ export function resolveVariantForModel(
const agentOverrides = config.agents as
| Record<string, { category?: string }>
| undefined
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
const agentOverride = agentOverrides
? agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
: undefined
const categoryName = agentOverride?.category
if (categoryName) {
const categoryRequirement = CATEGORY_MODEL_REQUIREMENTS[categoryName]

View File

@@ -0,0 +1,60 @@
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import * as path from "node:path";
import { spawn } from "bun";
import { extractZip } from "./zip-extractor";
export function getCachedBinaryPath(cacheDir: string, binaryName: string): string | null {
const binaryPath = path.join(cacheDir, binaryName);
return existsSync(binaryPath) ? binaryPath : null;
}
export function ensureCacheDir(cacheDir: string): void {
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}
}
export async function downloadArchive(downloadUrl: string, archivePath: string): Promise<void> {
const response = await fetch(downloadUrl, { redirect: "follow" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
await Bun.write(archivePath, arrayBuffer);
}
export async function extractTarGz(
archivePath: string,
destDir: string,
options?: { args?: string[]; cwd?: string }
): Promise<void> {
const args = options?.args ?? ["tar", "-xzf", archivePath, "-C", destDir];
const proc = spawn(args, {
cwd: options?.cwd,
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`);
}
}
export async function extractZipArchive(archivePath: string, destDir: string): Promise<void> {
await extractZip(archivePath, destDir);
}
export function cleanupArchive(archivePath: string): void {
if (existsSync(archivePath)) {
unlinkSync(archivePath);
}
}
export function ensureExecutable(binaryPath: string): void {
if (process.platform !== "win32" && existsSync(binaryPath)) {
chmodSync(binaryPath, 0o755);
}
}

View File

@@ -1,169 +0,0 @@
import { describe, test, expect } from "bun:test"
import {
findCaseInsensitive,
includesCaseInsensitive,
findByNameCaseInsensitive,
equalsIgnoreCase,
} from "./case-insensitive"
describe("findCaseInsensitive", () => {
test("returns undefined for empty/undefined object", () => {
// #given - undefined object
const obj = undefined
// #when - lookup any key
const result = findCaseInsensitive(obj, "key")
// #then - returns undefined
expect(result).toBeUndefined()
})
test("finds exact match first", () => {
// #given - object with exact key
const obj = { Oracle: "value1", oracle: "value2" }
// #when - lookup with exact case
const result = findCaseInsensitive(obj, "Oracle")
// #then - returns exact match
expect(result).toBe("value1")
})
test("finds case-insensitive match when no exact match", () => {
// #given - object with lowercase key
const obj = { oracle: "value" }
// #when - lookup with uppercase
const result = findCaseInsensitive(obj, "ORACLE")
// #then - returns case-insensitive match
expect(result).toBe("value")
})
test("returns undefined when key not found", () => {
// #given - object without target key
const obj = { other: "value" }
// #when - lookup missing key
const result = findCaseInsensitive(obj, "oracle")
// #then - returns undefined
expect(result).toBeUndefined()
})
})
describe("includesCaseInsensitive", () => {
test("returns true for exact match", () => {
// #given - array with exact value
const arr = ["explore", "librarian"]
// #when - check exact match
const result = includesCaseInsensitive(arr, "explore")
// #then - returns true
expect(result).toBe(true)
})
test("returns true for case-insensitive match", () => {
// #given - array with lowercase values
const arr = ["explore", "librarian"]
// #when - check uppercase value
const result = includesCaseInsensitive(arr, "EXPLORE")
// #then - returns true
expect(result).toBe(true)
})
test("returns true for mixed case match", () => {
// #given - array with mixed case values
const arr = ["Oracle", "Sisyphus"]
// #when - check different case
const result = includesCaseInsensitive(arr, "oracle")
// #then - returns true
expect(result).toBe(true)
})
test("returns false when value not found", () => {
// #given - array without target value
const arr = ["explore", "librarian"]
// #when - check missing value
const result = includesCaseInsensitive(arr, "oracle")
// #then - returns false
expect(result).toBe(false)
})
test("returns false for empty array", () => {
// #given - empty array
const arr: string[] = []
// #when - check any value
const result = includesCaseInsensitive(arr, "explore")
// #then - returns false
expect(result).toBe(false)
})
})
describe("findByNameCaseInsensitive", () => {
test("finds element by exact name", () => {
// #given - array with named objects
const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }]
// #when - find by exact name
const result = findByNameCaseInsensitive(arr, "Oracle")
// #then - returns matching element
expect(result).toEqual({ name: "Oracle", value: 1 })
})
test("finds element by case-insensitive name", () => {
// #given - array with named objects
const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }]
// #when - find by different case
const result = findByNameCaseInsensitive(arr, "oracle")
// #then - returns matching element
expect(result).toEqual({ name: "Oracle", value: 1 })
})
test("returns undefined when name not found", () => {
// #given - array without target name
const arr = [{ name: "Oracle", value: 1 }]
// #when - find missing name
const result = findByNameCaseInsensitive(arr, "librarian")
// #then - returns undefined
expect(result).toBeUndefined()
})
})
describe("equalsIgnoreCase", () => {
test("returns true for same case", () => {
// #given - same strings
// #when - compare
// #then - returns true
expect(equalsIgnoreCase("oracle", "oracle")).toBe(true)
})
test("returns true for different case", () => {
// #given - strings with different case
// #when - compare
// #then - returns true
expect(equalsIgnoreCase("Oracle", "ORACLE")).toBe(true)
expect(equalsIgnoreCase("Sisyphus-Junior", "sisyphus-junior")).toBe(true)
})
test("returns false for different strings", () => {
// #given - different strings
// #when - compare
// #then - returns false
expect(equalsIgnoreCase("oracle", "explore")).toBe(false)
})
})

View File

@@ -1,46 +0,0 @@
/**
* Case-insensitive lookup and comparison utilities for agent/config names.
* Used throughout the codebase to allow "Oracle", "oracle", "ORACLE" to work the same.
*/
/**
* Find a value in an object using case-insensitive key matching.
* First tries exact match, then falls back to lowercase comparison.
*/
export function findCaseInsensitive<T>(obj: Record<string, T> | undefined, key: string): T | undefined {
if (!obj) return undefined
const exactMatch = obj[key]
if (exactMatch !== undefined) return exactMatch
const lowerKey = key.toLowerCase()
for (const [k, v] of Object.entries(obj)) {
if (k.toLowerCase() === lowerKey) return v
}
return undefined
}
/**
* Check if an array includes a value using case-insensitive comparison.
*/
export function includesCaseInsensitive(arr: string[], value: string): boolean {
const lowerValue = value.toLowerCase()
return arr.some((item) => item.toLowerCase() === lowerValue)
}
/**
* Find an element in array using case-insensitive name matching.
* Useful for finding agents/categories by name.
*/
export function findByNameCaseInsensitive<T extends { name: string }>(
arr: T[],
name: string
): T | undefined {
const lowerName = name.toLowerCase()
return arr.find((item) => item.name.toLowerCase() === lowerName)
}
/**
* Check if two strings are equal (case-insensitive).
*/
export function equalsIgnoreCase(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase()
}

View File

@@ -20,6 +20,7 @@ export * from "./opencode-version"
export * from "./permission-compat"
export * from "./external-plugin-detector"
export * from "./zip-extractor"
export * from "./binary-downloader"
export * from "./agent-variant"
export * from "./session-cursor"
export * from "./shell-env"
@@ -27,9 +28,14 @@ export * from "./system-directive"
export * from "./agent-tool-restrictions"
export * from "./model-requirements"
export * from "./model-resolver"
export {
resolveModelPipeline,
type ModelResolutionRequest,
type ModelResolutionResult as ModelResolutionPipelineResult,
type ModelResolutionProvenance,
} from "./model-resolution-pipeline"
export * from "./model-availability"
export * from "./connected-providers-cache"
export * from "./case-insensitive"
export * from "./session-utils"
export * from "./tmux"
export * from "./model-suggestion-retry"

View File

@@ -0,0 +1,174 @@
import { log } from "./logger"
import { readConnectedProvidersCache } from "./connected-providers-cache"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
export type ModelResolutionRequest = {
intent?: {
uiSelectedModel?: string
userModel?: string
categoryDefaultModel?: string
}
constraints: {
availableModels: Set<string>
}
policy?: {
fallbackChain?: FallbackEntry[]
systemDefaultModel?: string
}
}
export type ModelResolutionProvenance =
| "override"
| "category-default"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
provenance: ModelResolutionProvenance
variant?: string
attempted?: string[]
reason?: string
}
function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim()
return trimmed || undefined
}
export function resolveModelPipeline(
request: ModelResolutionRequest,
): ModelResolutionResult | undefined {
const attempted: string[] = []
const { intent, constraints, policy } = request
const availableModels = constraints.availableModels
const fallbackChain = policy?.fallbackChain
const systemDefaultModel = policy?.systemDefaultModel
const normalizedUiModel = normalizeModel(intent?.uiSelectedModel)
if (normalizedUiModel) {
log("Model resolved via UI selection", { model: normalizedUiModel })
return { model: normalizedUiModel, provenance: "override" }
}
const normalizedUserModel = normalizeModel(intent?.userModel)
if (normalizedUserModel) {
log("Model resolved via config override", { model: normalizedUserModel })
return { model: normalizedUserModel, provenance: "override" }
}
const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel)
if (normalizedCategoryDefault) {
attempted.push(normalizedCategoryDefault)
if (availableModels.size > 0) {
const parts = normalizedCategoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
if (match) {
log("Model resolved via category default (fuzzy matched)", {
original: normalizedCategoryDefault,
matched: match,
})
return { model: match, provenance: "category-default", attempted }
}
} else {
const connectedProviders = readConnectedProvidersCache()
if (connectedProviders === null) {
log("Model resolved via category default (no cache, first run)", {
model: normalizedCategoryDefault,
})
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
}
const parts = normalizedCategoryDefault.split("/")
if (parts.length >= 2) {
const provider = parts[0]
if (connectedProviders.includes(provider)) {
log("Model resolved via category default (connected provider)", {
model: normalizedCategoryDefault,
})
return { model: normalizedCategoryDefault, provenance: "category-default", attempted }
}
}
}
log("Category default model not available, falling through to fallback chain", {
model: normalizedCategoryDefault,
})
}
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet === null) {
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const model = `${provider}/${entry.model}`
log("Model resolved via fallback chain (connected provider)", {
provider,
model: entry.model,
variant: entry.variant,
})
return {
model,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
}
log("No connected provider found in fallback chain, falling through to system default")
}
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", {
provider,
model: entry.model,
match,
variant: entry.variant,
})
return {
model: match,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
if (crossProviderMatch) {
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: entry.model,
match: crossProviderMatch,
variant: entry.variant,
})
return {
model: crossProviderMatch,
provenance: "provider-fallback",
variant: entry.variant,
attempted,
}
}
}
log("No available model found in fallback chain, falling through to system default")
}
}
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, provenance: "system-default", attempted }
}

View File

@@ -1,7 +1,6 @@
import { log } from "./logger"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
import { readConnectedProvidersCache } from "./connected-providers-cache"
import { resolveModelPipeline } from "./model-resolution-pipeline"
export type ModelResolutionInput = {
userModel?: string
@@ -47,107 +46,19 @@ export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult | undefined {
const { uiSelectedModel, userModel, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const resolved = resolveModelPipeline({
intent: { uiSelectedModel, userModel, categoryDefaultModel },
constraints: { availableModels },
policy: { fallbackChain, systemDefaultModel },
})
// Step 1: UI Selection (highest priority - respects user's model choice in OpenCode UI)
const normalizedUiModel = normalizeModel(uiSelectedModel)
if (normalizedUiModel) {
log("Model resolved via UI selection", { model: normalizedUiModel })
return { model: normalizedUiModel, source: "override" }
}
// Step 2: Config Override (from oh-my-opencode.json user config)
const normalizedUserModel = normalizeModel(userModel)
if (normalizedUserModel) {
log("Model resolved via config override", { model: normalizedUserModel })
return { model: normalizedUserModel, source: "override" }
}
// Step 2.5: Category Default Model (from DEFAULT_CATEGORIES, with fuzzy matching)
const normalizedCategoryDefault = normalizeModel(categoryDefaultModel)
if (normalizedCategoryDefault) {
if (availableModels.size > 0) {
const parts = normalizedCategoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
if (match) {
log("Model resolved via category default (fuzzy matched)", { original: normalizedCategoryDefault, matched: match })
return { model: match, source: "category-default" }
}
} else {
const connectedProviders = readConnectedProvidersCache()
if (connectedProviders === null) {
log("Model resolved via category default (no cache, first run)", { model: normalizedCategoryDefault })
return { model: normalizedCategoryDefault, source: "category-default" }
}
const parts = normalizedCategoryDefault.split("/")
if (parts.length >= 2) {
const provider = parts[0]
if (connectedProviders.includes(provider)) {
log("Model resolved via category default (connected provider)", { model: normalizedCategoryDefault })
return { model: normalizedCategoryDefault, source: "category-default" }
}
}
}
log("Category default model not available, falling through to fallback chain", { model: normalizedCategoryDefault })
}
// Step 3: Provider fallback chain (exact match → fuzzy match → next provider)
if (fallbackChain && fallbackChain.length > 0) {
if (availableModels.size === 0) {
const connectedProviders = readConnectedProvidersCache()
const connectedSet = connectedProviders ? new Set(connectedProviders) : null
if (connectedSet === null) {
log("Model fallback chain skipped (no connected providers cache) - falling through to system default")
} else {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const model = `${provider}/${entry.model}`
log("Model resolved via fallback chain (connected provider)", {
provider,
model: entry.model,
variant: entry.variant,
})
return { model, source: "provider-fallback", variant: entry.variant }
}
}
}
log("No connected provider found in fallback chain, falling through to system default")
}
} else {
for (const entry of fallbackChain) {
// Step 1: Try with provider filter (preferred providers first)
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", { provider, model: entry.model, match, variant: entry.variant })
return { model: match, source: "provider-fallback", variant: entry.variant }
}
}
// Step 2: Try without provider filter (cross-provider fuzzy match)
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
if (crossProviderMatch) {
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: entry.model,
match: crossProviderMatch,
variant: entry.variant,
})
return { model: crossProviderMatch, source: "provider-fallback", variant: entry.variant }
}
}
log("No available model found in fallback chain, falling through to system default")
}
}
// Step 4: System default (if provided)
if (systemDefaultModel === undefined) {
log("No model resolved - systemDefaultModel not configured")
if (!resolved) {
return undefined
}
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, source: "system-default" }
return {
model: resolved.model,
source: resolved.provenance,
variant: resolved.variant,
}
}

View File

@@ -2,8 +2,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import {
parseVersion,
compareVersions,
isVersionGte,
isVersionLt,
getOpenCodeVersion,
isOpenCodeVersionAtLeast,
resetVersionCache,
@@ -103,32 +101,6 @@ describe("opencode-version", () => {
})
})
describe("isVersionGte", () => {
test("returns true when a >= b", () => {
expect(isVersionGte("1.1.1", "1.1.1")).toBe(true)
expect(isVersionGte("1.1.2", "1.1.1")).toBe(true)
expect(isVersionGte("1.2.0", "1.1.1")).toBe(true)
expect(isVersionGte("2.0.0", "1.1.1")).toBe(true)
})
test("returns false when a < b", () => {
expect(isVersionGte("1.1.0", "1.1.1")).toBe(false)
expect(isVersionGte("1.0.9", "1.1.1")).toBe(false)
expect(isVersionGte("0.9.9", "1.1.1")).toBe(false)
})
})
describe("isVersionLt", () => {
test("returns true when a < b", () => {
expect(isVersionLt("1.1.0", "1.1.1")).toBe(true)
expect(isVersionLt("1.0.150", "1.1.1")).toBe(true)
})
test("returns false when a >= b", () => {
expect(isVersionLt("1.1.1", "1.1.1")).toBe(false)
expect(isVersionLt("1.1.2", "1.1.1")).toBe(false)
})
})
describe("getOpenCodeVersion", () => {
beforeEach(() => {

View File

@@ -37,13 +37,6 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
return 0
}
export function isVersionGte(a: string, b: string): boolean {
return compareVersions(a, b) >= 0
}
export function isVersionLt(a: string, b: string): boolean {
return compareVersions(a, b) < 0
}
export function getOpenCodeVersion(): string | null {
if (cachedVersion !== NOT_CACHED) {
@@ -69,7 +62,7 @@ export function getOpenCodeVersion(): string | null {
export function isOpenCodeVersionAtLeast(version: string): boolean {
const current = getOpenCodeVersion()
if (!current) return true
return isVersionGte(current, version)
return compareVersions(current, version) >= 0
}
export function resetVersionCache(): void {

View File

@@ -0,0 +1,59 @@
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}
export function createInjectedPathsStorage(storageDir: string) {
const getStoragePath = (sessionID: string): string =>
join(storageDir, `${sessionID}.json`);
const loadInjectedPaths = (sessionID: string): Set<string> => {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
};
const saveInjectedPaths = (sessionID: string, paths: Set<string>): void => {
if (!existsSync(storageDir)) {
mkdirSync(storageDir, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
};
const clearInjectedPaths = (sessionID: string): void => {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
};
return {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
};
}

View File

@@ -8,42 +8,37 @@ export function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
export function transformObjectKeys(
obj: Record<string, unknown>,
transformer: (key: string) => string,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const transformedKey = transformer(key)
if (deep && isPlainObject(value)) {
result[transformedKey] = transformObjectKeys(value, transformer, true)
} else if (deep && Array.isArray(value)) {
result[transformedKey] = value.map((item) =>
isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item
)
} else {
result[transformedKey] = value
}
}
return result
}
export function objectToSnakeCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const snakeKey = camelToSnake(key)
if (deep && isPlainObject(value)) {
result[snakeKey] = objectToSnakeCase(value, true)
} else if (deep && Array.isArray(value)) {
result[snakeKey] = value.map((item) =>
isPlainObject(item) ? objectToSnakeCase(item, true) : item
)
} else {
result[snakeKey] = value
}
}
return result
}
return transformObjectKeys(obj, camelToSnake, deep)
}
export function objectToCamelCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const camelKey = snakeToCamel(key)
if (deep && isPlainObject(value)) {
result[camelKey] = objectToCamelCase(value, true)
} else if (deep && Array.isArray(value)) {
result[camelKey] = value.map((item) =>
isPlainObject(item) ? objectToCamelCase(item, true) : item
)
} else {
result[camelKey] = value
}
}
return result
}
return transformObjectKeys(obj, snakeToCamel, deep)
}

View File

@@ -1,8 +1,15 @@
import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs"
import { existsSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { createRequire } from "module"
import { extractZip } from "../../shared"
import {
cleanupArchive,
downloadArchive,
ensureCacheDir,
ensureExecutable,
extractZipArchive,
getCachedBinaryPath as getCachedBinaryPathShared,
} from "../../shared/binary-downloader"
import { log } from "../../shared/logger"
const REPO = "ast-grep/ast-grep"
@@ -53,8 +60,7 @@ export function getBinaryName(): string {
}
export function getCachedBinaryPath(): string | null {
const binaryPath = join(getCacheDir(), getBinaryName())
return existsSync(binaryPath) ? binaryPath : null
return getCachedBinaryPathShared(getCacheDir(), getBinaryName())
}
@@ -83,29 +89,12 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis
log(`[oh-my-opencode] Downloading ast-grep binary...`)
try {
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true })
}
const response = await fetch(downloadUrl, { redirect: "follow" })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const archivePath = join(cacheDir, assetName)
const arrayBuffer = await response.arrayBuffer()
await Bun.write(archivePath, arrayBuffer)
await extractZip(archivePath, cacheDir)
if (existsSync(archivePath)) {
unlinkSync(archivePath)
}
if (process.platform !== "win32" && existsSync(binaryPath)) {
chmodSync(binaryPath, 0o755)
}
ensureCacheDir(cacheDir)
await downloadArchive(downloadUrl, archivePath)
await extractZipArchive(archivePath, cacheDir)
cleanupArchive(archivePath)
ensureExecutable(binaryPath)
log(`[oh-my-opencode] ast-grep binary ready.`)

View File

@@ -4,7 +4,7 @@ import { join } from "node:path"
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import { log, getAgentToolRestrictions, includesCaseInsensitive } from "../../shared"
import { log, getAgentToolRestrictions } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
@@ -58,7 +58,9 @@ export function createCallOmoAgent(
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
// Case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
if (!includesCaseInsensitive([...ALLOWED_AGENTS], args.subagent_type)) {
if (![...ALLOWED_AGENTS].some(
(name) => name.toLowerCase() === args.subagent_type.toLowerCase()
)) {
return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.`
}

View File

@@ -12,10 +12,9 @@ import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log, getAgentToolRestrictions, resolveModel, getOpenCodeConfigPaths, findByNameCaseInsensitive, equalsIgnoreCase, promptWithModelSuggestionRetry } from "../../shared"
import { log, getAgentToolRestrictions, resolveModel, resolveModelPipeline, getOpenCodeConfigPaths, promptWithModelSuggestionRetry } from "../../shared"
import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability"
import { readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { resolveModelWithFallback } from "../../shared/model-resolver"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
type OpencodeClient = PluginInput["client"]
@@ -552,16 +551,20 @@ To continue this session: session_id="${args.session_id}"`
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
}
} else {
const resolution = resolveModelWithFallback({
const resolution = resolveModelPipeline({
intent: {
userModel: userCategories?.[args.category]?.model,
categoryDefaultModel: resolved.model ?? sisyphusJuniorModel,
},
constraints: { availableModels },
policy: {
fallbackChain: requirement.fallbackChain,
availableModels,
systemDefaultModel,
})
},
})
if (resolution) {
const { model: resolvedModel, source, variant: resolvedVariant } = resolution
if (resolution) {
const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution
actualModel = resolvedModel
if (!parseModelString(actualModel)) {
@@ -569,7 +572,8 @@ To continue this session: session_id="${args.session_id}"`
}
let type: "user-defined" | "inherited" | "category-default" | "system-default"
switch (source) {
const source = provenance
switch (provenance) {
case "override":
type = "user-defined"
break
@@ -582,7 +586,7 @@ To continue this session: session_id="${args.session_id}"`
break
}
modelInfo = { model: actualModel, type, source }
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant ?? resolved.config.variant
@@ -780,7 +784,7 @@ To continue this session: session_id="${sessionID}"`
}
const agentName = args.subagent_type.trim()
if (equalsIgnoreCase(agentName, SISYPHUS_JUNIOR_AGENT)) {
if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) {
return `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`
@@ -803,12 +807,13 @@ Create the work plan directly - that's your job as the planning agent.`
const callableAgents = agents.filter((a) => a.mode !== "primary")
const matchedAgent = findByNameCaseInsensitive(callableAgents, agentToUse)
const matchedAgent = callableAgents.find(
(agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()
)
if (!matchedAgent) {
const isPrimaryAgent = findByNameCaseInsensitive(
agents.filter((a) => a.mode === "primary"),
agentToUse
)
const isPrimaryAgent = agents
.filter((a) => a.mode === "primary")
.find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase())
if (isPrimaryAgent) {
return `Cannot call primary agent "${isPrimaryAgent.name}" via delegate_task. Primary agents are top-level orchestrators.`
}

View File

@@ -1,7 +1,13 @@
import { existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync } from "node:fs"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { spawn } from "bun"
import { extractZip as extractZipBase } from "../../shared"
import {
cleanupArchive,
downloadArchive,
ensureCacheDir,
ensureExecutable,
extractTarGz as extractTarGzArchive,
} from "../../shared/binary-downloader"
export function findFileRecursive(dir: string, filename: string): string | null {
try {
@@ -41,16 +47,6 @@ function getRgPath(): string {
return join(getInstallDir(), isWindows ? "rg.exe" : "rg")
}
async function downloadFile(url: string, destPath: string): Promise<void> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
}
const buffer = await response.arrayBuffer()
await Bun.write(destPath, buffer)
}
async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
const platformKey = getPlatformKey()
@@ -62,17 +58,7 @@ async function extractTarGz(archivePath: string, destDir: string): Promise<void>
args.push("--wildcards", "*/rg")
}
const proc = spawn(args, {
cwd: destDir,
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
throw new Error(`Failed to extract tar.gz: ${stderr}`)
}
await extractTarGzArchive(archivePath, destDir, { args, cwd: destDir })
}
async function extractZip(archivePath: string, destDir: string): Promise<void> {
@@ -104,14 +90,14 @@ export async function downloadAndInstallRipgrep(): Promise<string> {
return rgPath
}
mkdirSync(installDir, { recursive: true })
ensureCacheDir(installDir)
const filename = `ripgrep-${RG_VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}`
const archivePath = join(installDir, filename)
try {
await downloadFile(url, archivePath)
await downloadArchive(url, archivePath)
if (config.extension === "tar.gz") {
await extractTarGz(archivePath, installDir)
@@ -119,9 +105,7 @@ export async function downloadAndInstallRipgrep(): Promise<string> {
await extractZip(archivePath, installDir)
}
if (process.platform !== "win32") {
chmodSync(rgPath, 0o755)
}
ensureExecutable(rgPath)
if (!existsSync(rgPath)) {
throw new Error("ripgrep binary not found after extraction")
@@ -129,12 +113,10 @@ export async function downloadAndInstallRipgrep(): Promise<string> {
return rgPath
} finally {
if (existsSync(archivePath)) {
try {
unlinkSync(archivePath)
} catch {
// Cleanup failures are non-critical
}
try {
cleanupArchive(archivePath)
} catch {
// Cleanup failures are non-critical
}
}
}

View File

@@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url"
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
import type { LookAtArgs } from "./types"
import { findByNameCaseInsensitive, log, promptWithModelSuggestionRetry } from "../../shared"
import { log, promptWithModelSuggestionRetry } from "../../shared"
interface LookAtArgsWithAlias extends LookAtArgs {
path?: string
@@ -143,7 +143,9 @@ Original error: ${createResult.error}`
}
const agents = ((agentsResult as { data?: AgentInfo[] })?.data ?? agentsResult) as AgentInfo[] | undefined
if (agents?.length) {
const matchedAgent = findByNameCaseInsensitive(agents, MULTIMODAL_LOOKER_AGENT)
const matchedAgent = agents.find(
(agent) => agent.name.toLowerCase() === MULTIMODAL_LOOKER_AGENT.toLowerCase()
)
if (matchedAgent?.model) {
agentModel = matchedAgent.model
}