Merge pull request #3492 from code-yeongyu/refactor/legacy-plugin-decoupling

refactor: modernize plugin entry to V1 format and decouple legacy/tightly-coupled code
This commit is contained in:
YeonGyu-Kim
2026-04-18 03:10:14 +09:00
committed by GitHub
60 changed files with 1590 additions and 1429 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ OpenCode plugin (npm: `oh-my-opencode`, dual-published as `oh-my-openagent` duri
```
oh-my-opencode/
├── src/
│ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface
│ ├── index.ts # Plugin entry: default export `pluginModule`, shape `{ id, server }`
│ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4)
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
│ ├── hooks/ # 52 lifecycle hooks across dedicated modules and standalone files
@@ -33,7 +33,7 @@ oh-my-opencode/
## INITIALIZATION FLOW
```
OhMyOpenCodePlugin(ctx)
pluginModule.server(input, options)
├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate
├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler
├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools)
+1 -1
View File
@@ -109,7 +109,7 @@ After making changes, you can test your local build in OpenCode:
```
oh-my-opencode/
├── src/
│ ├── index.ts # Plugin entry (OhMyOpenCodePlugin)
│ ├── index.ts # Plugin entry (V1 PluginModule, default export)
│ ├── plugin-config.ts # JSONC multi-level config (Zod v4)
│ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior)
│ ├── hooks/ # 52 lifecycle hooks across 55 dedicated modules
+1 -1
View File
@@ -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) |
+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
+7 -1
View File
@@ -14,7 +14,13 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
export { createThinkModeHook } from "./think-mode";
export { createModelFallbackHook, setPendingModelFallback, clearPendingModelFallback, type ModelFallbackState } from "./model-fallback/hook";
export {
createModelFallbackHook,
setPendingModelFallback,
clearPendingModelFallback,
type ModelFallbackHook,
type ModelFallbackState,
} from "./model-fallback/hook";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
export { createRulesInjectorHook } from "./rules-injector";
export { createBackgroundNotificationHook } from "./background-notification"
@@ -0,0 +1,30 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { ModelFallbackStateController } from "./fallback-state-controller"
export type ModelFallbackControllerAccessor = {
register: (controller: ModelFallbackStateController) => void
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
clearSessionFallbackChain: (sessionID: string) => void
}
export function createModelFallbackControllerAccessor(): ModelFallbackControllerAccessor {
let controller: ModelFallbackStateController | null = null
function register(nextController: ModelFallbackStateController): void {
controller = nextController
}
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
controller?.setSessionFallbackChain(sessionID, fallbackChain)
}
function clearSessionFallbackChain(sessionID: string): void {
controller?.clearSessionFallbackChain(sessionID)
}
return {
register,
setSessionFallbackChain,
clearSessionFallbackChain,
}
}
@@ -0,0 +1,134 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { log } from "../../shared/logger"
import { getNextReachableFallback } from "./next-fallback"
type ModelFallbackStateLike = {
providerID: string
modelID: string
fallbackChain: FallbackEntry[]
attemptCount: number
pending: boolean
}
export type ModelFallbackStateController = {
lastToastKey: Map<string, string>
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
clearSessionFallbackChain: (sessionID: string) => void
setPendingModelFallback: (
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
) => boolean
getNextFallback: (sessionID: string) => ReturnType<typeof getNextReachableFallback>
clearPendingModelFallback: (sessionID: string) => void
hasPendingModelFallback: (sessionID: string) => boolean
getFallbackState: (sessionID: string) => ModelFallbackStateLike | undefined
reset: () => void
}
export function createModelFallbackStateController(input: {
pendingModelFallbacks: Map<string, ModelFallbackStateLike>
lastToastKey: Map<string, string>
sessionFallbackChains: Map<string, FallbackEntry[]>
}): ModelFallbackStateController {
const { pendingModelFallbacks, lastToastKey, sessionFallbackChains } = input
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
sessionFallbackChains.set(sessionID, fallbackChain?.length ? fallbackChain : [])
}
function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
}
function setPendingModelFallback(
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const fallbackChain = sessionFallbackChains.get(sessionID) ?? requirements?.fallbackChain
if (!fallbackChain?.length) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (!existing) {
pendingModelFallbacks.set(sessionID, {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
})
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
}
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
function getNextFallback(sessionID: string): ReturnType<typeof getNextReachableFallback> {
const state = pendingModelFallbacks.get(sessionID)
if (!state?.pending) return null
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) return fallback
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
}
function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
}
function hasPendingModelFallback(sessionID: string): boolean {
return pendingModelFallbacks.get(sessionID)?.pending === true
}
function getFallbackState(sessionID: string): ModelFallbackStateLike | undefined {
return pendingModelFallbacks.get(sessionID)
}
function reset(): void {
pendingModelFallbacks.clear()
lastToastKey.clear()
sessionFallbackChains.clear()
}
return {
lastToastKey,
setSessionFallbackChain,
clearSessionFallbackChain,
setPendingModelFallback,
getNextFallback,
clearPendingModelFallback,
hasPendingModelFallback,
getFallbackState,
reset,
}
}
+44 -33
View File
@@ -70,22 +70,23 @@ const {
setPendingModelFallback,
} = await importFreshModelFallbackHookModule()
type ModelFallbackHook = ReturnType<typeof createModelFallbackHook>
describe("model fallback hook", () => {
let modelFallback: ModelFallbackHook
beforeEach(() => {
modelFallback = createModelFallbackHook()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
readConnectedProvidersCacheMock.mockClear()
readProviderModelsCacheMock.mockClear()
selectFallbackProviderMock.mockClear()
clearPendingModelFallback("ses_model_fallback_main")
clearPendingModelFallback("ses_model_fallback_ghcp")
clearPendingModelFallback("ses_model_fallback_google")
})
test("applies pending fallback on chat.message by overriding model", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -93,6 +94,7 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
modelFallback,
"ses_model_fallback_main",
"Sisyphus - Ultraworker",
"anthropic",
@@ -123,7 +125,7 @@ describe("model fallback hook", () => {
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -132,7 +134,7 @@ describe("model fallback hook", () => {
const sessionID = "ses_model_fallback_main"
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
).toBe(true)
const firstOutput = {
@@ -154,7 +156,7 @@ describe("model fallback hook", () => {
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
).toBe(true)
const secondOutput = {
@@ -176,16 +178,18 @@ describe("model fallback hook", () => {
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
//#when
const firstSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-7-thinking",
)
const secondSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
@@ -195,28 +199,29 @@ describe("model fallback hook", () => {
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
@@ -239,28 +244,29 @@ describe("model fallback hook", () => {
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"quotio",
@@ -285,28 +291,29 @@ describe("model fallback hook", () => {
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
//#given
const sessionID = "ses_model_fallback_preferred_provider"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["provider-y"], model: "fallback-model" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"provider-x",
@@ -329,17 +336,18 @@ describe("model fallback hook", () => {
providerID: "provider-x",
modelID: "fallback-model",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
//#given
const sessionID = "ses_model_fallback_explicit_none"
clearPendingModelFallback(sessionID)
setSessionFallbackChain(sessionID, undefined)
clearPendingModelFallback(modelFallback, sessionID)
setSessionFallbackChain(modelFallback, sessionID, undefined)
//#when
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Junior",
"anthropic",
@@ -348,7 +356,7 @@ describe("model fallback hook", () => {
//#then
expect(set).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("shows toast when fallback is applied", async () => {
@@ -366,6 +374,7 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
hook,
"ses_model_fallback_toast",
"Sisyphus - Ultraworker",
"anthropic",
@@ -392,9 +401,9 @@ describe("model fallback hook", () => {
test("transforms model names for github-copilot provider via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_ghcp"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -402,11 +411,12 @@ describe("model fallback hook", () => {
}
// Set a custom fallback chain that routes through github-copilot
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Atlas - Plan Executor",
"github-copilot",
@@ -430,15 +440,15 @@ describe("model fallback hook", () => {
modelID: "claude-sonnet-4.6",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("preserves canonical google preview model names via fallback chain", async () => {
//#given
const sessionID = "ses_model_fallback_google"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -446,11 +456,12 @@ describe("model fallback hook", () => {
}
// Set a custom fallback chain that routes through google
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Oracle",
"google",
@@ -474,7 +485,7 @@ describe("model fallback hook", () => {
modelID: "gemini-3.1-pro-preview",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
})
+91 -103
View File
@@ -1,13 +1,11 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache"
import { selectFallbackProvider } from "../../shared/model-error-classifier"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { log } from "../../shared/logger"
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
import { applyFallbackToChatMessage } from "./chat-message-fallback-handler"
import { getNextReachableFallback } from "./next-fallback"
import {
createModelFallbackStateController,
type ModelFallbackStateController,
} from "./fallback-state-controller"
import type { ModelFallbackControllerAccessor } from "./controller-accessor"
type FallbackToast = (input: {
title: string
@@ -31,30 +29,45 @@ export type ModelFallbackState = {
pending: boolean
}
/**
* Map of sessionID -> pending model fallback state
* When a model error occurs, we store the fallback info here.
* The next chat.message call will use this to switch to the fallback model.
*/
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
type ModelFallbackControllerWithState = Pick<
ModelFallbackStateController,
| "lastToastKey"
| "setSessionFallbackChain"
| "clearSessionFallbackChain"
| "setPendingModelFallback"
| "getNextFallback"
| "clearPendingModelFallback"
| "hasPendingModelFallback"
| "getFallbackState"
| "reset"
>
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
if (!fallbackChain) {
sessionFallbackChains.set(sessionID, [])
return
}
if (fallbackChain.length === 0) {
sessionFallbackChains.set(sessionID, [])
return
}
sessionFallbackChains.set(sessionID, fallbackChain)
export type ModelFallbackHook = ModelFallbackControllerWithState & {
"chat.message": (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
) => Promise<void>
}
export function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
type ModelFallbackHookArgs = {
toast?: FallbackToast
onApplied?: FallbackCallback
controllerAccessor?: ModelFallbackControllerAccessor
}
export function setSessionFallbackChain(
controller: Pick<ModelFallbackStateController, "setSessionFallbackChain">,
sessionID: string,
fallbackChain: FallbackEntry[] | undefined,
): void {
controller.setSessionFallbackChain(sessionID, fallbackChain)
}
export function clearSessionFallbackChain(
controller: Pick<ModelFallbackStateController, "clearSessionFallbackChain">,
sessionID: string,
): void {
controller.clearSessionFallbackChain(sessionID)
}
/**
@@ -62,56 +75,18 @@ export function clearSessionFallbackChain(sessionID: string): void {
* Called when a model error is detected in session.error handler.
*/
export function setPendingModelFallback(
controller: Pick<ModelFallbackStateController, "setPendingModelFallback">,
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const hasSessionFallback = sessionFallbackChains.has(sessionID)
const sessionFallback = sessionFallbackChains.get(sessionID)
const fallbackChain = hasSessionFallback
? sessionFallback
: requirements?.fallbackChain
if (!fallbackChain || fallbackChain.length === 0) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (existing) {
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
// Preserve progression across repeated session.error retries in same session.
// We only mark the next turn as pending fallback application.
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
const state: ModelFallbackState = {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
}
pendingModelFallbacks.set(sessionID, state)
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
return controller.setPendingModelFallback(
sessionID,
agentName,
currentProviderID,
currentModelID,
)
}
/**
@@ -119,55 +94,71 @@ export function setPendingModelFallback(
* Increments attemptCount each time called.
*/
export function getNextFallback(
controller: Pick<ModelFallbackStateController, "getNextFallback">,
sessionID: string,
): { providerID: string; modelID: string; variant?: string } | null {
const state = pendingModelFallbacks.get(sessionID)
if (!state) return null
if (!state.pending) return null
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) {
return fallback
}
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
return controller.getNextFallback(sessionID)
}
/**
* Clears the pending fallback for a session.
* Called after fallback is successfully applied.
*/
export function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
export function clearPendingModelFallback(
controller: Pick<ModelFallbackStateController, "clearPendingModelFallback">,
sessionID: string,
): void {
controller.clearPendingModelFallback(sessionID)
}
/**
* Checks if there's a pending fallback for a session.
*/
export function hasPendingModelFallback(sessionID: string): boolean {
const state = pendingModelFallbacks.get(sessionID)
return state?.pending === true
export function hasPendingModelFallback(
controller: Pick<ModelFallbackStateController, "hasPendingModelFallback">,
sessionID: string,
): boolean {
return controller.hasPendingModelFallback(sessionID)
}
/**
* Gets the current fallback state for a session (for debugging).
*/
export function getFallbackState(sessionID: string): ModelFallbackState | undefined {
return pendingModelFallbacks.get(sessionID)
export function getFallbackState(
controller: Pick<ModelFallbackStateController, "getFallbackState">,
sessionID: string,
): ModelFallbackState | undefined {
return controller.getFallbackState(sessionID)
}
/**
* Creates a chat.message hook that applies model fallbacks when pending.
*/
export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplied?: FallbackCallback }) {
export function createModelFallbackHook(args?: ModelFallbackHookArgs): ModelFallbackHook {
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
const controller = createModelFallbackStateController({
pendingModelFallbacks,
lastToastKey,
sessionFallbackChains,
})
args?.controllerAccessor?.register(controller)
const toast = args?.toast
const onApplied = args?.onApplied
return {
lastToastKey: controller.lastToastKey,
setSessionFallbackChain: controller.setSessionFallbackChain,
clearSessionFallbackChain: controller.clearSessionFallbackChain,
setPendingModelFallback: controller.setPendingModelFallback,
getNextFallback: controller.getNextFallback,
clearPendingModelFallback: controller.clearPendingModelFallback,
hasPendingModelFallback: controller.hasPendingModelFallback,
getFallbackState: controller.getFallbackState,
reset: controller.reset,
"chat.message": async (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
@@ -175,7 +166,7 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
const { sessionID } = input
if (!sessionID) return
const fallback = getNextFallback(sessionID)
const fallback = getNextFallback(controller, sessionID)
if (!fallback) return
await applyFallbackToChatMessage({
@@ -184,18 +175,15 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
fallback,
toast,
onApplied,
lastToastKey,
lastToastKey: controller.lastToastKey,
})
},
}
}
/**
* Resets all module-global state for testing.
* Clears pending fallbacks, toast keys, and session chains.
* Resets hook-owned state for testing.
*/
export function _resetForTesting(): void {
pendingModelFallbacks.clear()
lastToastKey.clear()
sessionFallbackChains.clear()
export function _resetForTesting(controller?: Pick<ModelFallbackStateController, "reset">): void {
controller?.reset()
}
+2
View File
@@ -0,0 +1,2 @@
export { createModelFallbackControllerAccessor } from "./controller-accessor"
export type { ModelFallbackControllerAccessor } from "./controller-accessor"
+131
View File
@@ -0,0 +1,131 @@
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { log } from "../shared/logger"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import type {
CachedCompactionState,
PreemptiveCompactionContext,
} from "./preemptive-compaction-types"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
export async function runPreemptiveCompactionIfNeeded(args: {
ctx: PreemptiveCompactionContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState?: ContextLimitModelCacheState
sessionID: string
tokenCache: Map<string, CachedCompactionState>
compactionInProgress: Set<string>
compactedSessions: Set<string>
lastCompactionTime: Map<string, number>
}): Promise<void> {
const {
ctx,
pluginConfig,
modelCacheState,
sessionID,
tokenCache,
compactionInProgress,
compactedSessions,
lastCompactionTime,
} = args
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true },
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} finally {
compactionInProgress.delete(sessionID)
}
}
+41
View File
@@ -0,0 +1,41 @@
export interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
export interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
export interface PreemptiveCompactionClient {
session: {
messages: (input: {
path: { id: string }
query?: { directory: string }
}) => Promise<unknown>
summarize: (input: {
path: { id: string }
body: { providerID: string; modelID: string; auto?: boolean }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
showToast: (input: {
body: {
title: string
message: string
variant: "warning"
duration: number
}
}) => Promise<unknown>
}
}
export interface PreemptiveCompactionContext {
client: PreemptiveCompactionClient
directory: string
}
+17 -132
View File
@@ -1,69 +1,16 @@
import { log } from "../shared/logger"
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
type PluginInput = {
client: {
session: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: (...args: any[]) => any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
summarize: (...args: any[]) => any
}
tui: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
showToast: (...args: any[]) => any
}
}
directory: string
}
import { runPreemptiveCompactionIfNeeded } from "./preemptive-compaction-trigger"
import type {
CachedCompactionState,
PreemptiveCompactionContext,
TokenInfo,
} from "./preemptive-compaction-types"
export function createPreemptiveCompactionHook(
ctx: PluginInput,
ctx: PreemptiveCompactionContext,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ContextLimitModelCacheState,
) {
@@ -84,78 +31,16 @@ export function createPreemptiveCompactionHook(
input: { tool: string; sessionID: string; callID: string },
_output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
await runPreemptiveCompactionIfNeeded({
ctx,
pluginConfig,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} finally {
compactionInProgress.delete(sessionID)
}
sessionID: input.sessionID,
tokenCache,
compactionInProgress,
compactedSessions,
lastCompactionTime,
})
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -21,9 +21,9 @@ function createDeferred(): {
}
}
async function waitUntil(condition: () => boolean): Promise<void> {
async function waitUntil(shouldTrigger: () => boolean): Promise<void> {
for (let index = 0; index < 100; index++) {
if (condition()) {
if (shouldTrigger()) {
return
}
@@ -1,4 +1,4 @@
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
import { parseModelString } from "../../shared/model-string-parser"
export function buildRetryModelPayload(
model: string,
@@ -0,0 +1,51 @@
type EventProperties = Record<string, unknown> | undefined
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getEventInfo(properties: EventProperties): Record<string, unknown> | undefined {
const info = properties?.info
return isRecord(info) ? info : undefined
}
export function getSessionID(properties: EventProperties): string | undefined {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = getEventInfo(properties)
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
export function getEventToolName(properties: EventProperties): string | undefined {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
export function getQuestionText(properties: EventProperties): string {
const args = properties?.args
if (!isRecord(args)) return ""
const questions = args.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0]
if (!isRecord(firstQuestion)) return ""
const questionText = firstQuestion.question
return typeof questionText === "string" ? questionText : ""
}
+5 -37
View File
@@ -8,6 +8,11 @@ import {
type Platform,
} from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import {
getEventToolName,
getQuestionText,
getSessionID,
} from "./session-notification-event-properties"
import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
@@ -85,23 +90,6 @@ export function createSessionNotification(
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = properties?.info as Record<string, unknown> | undefined
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
@@ -113,26 +101,6 @@ export function createSessionNotification(
return true
}
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
const args = properties?.args as Record<string, unknown> | undefined
const questions = args?.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0] as Record<string, unknown> | undefined
const questionText = firstQuestion?.question
return typeof questionText === "string" ? questionText : ""
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it, mock } from "bun:test"
function createCompactingHandler(hooks: {
compactionContextInjector?: {
capture: (sessionID: string) => Promise<void>
inject: (sessionID: string) => string
}
compactionTodoPreserver?: { capture: (sessionID: string) => Promise<void> }
claudeCodeHooks?: {
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[] },
) => Promise<void>
}
}) {
return async (
input: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(input.sessionID)
await hooks.compactionTodoPreserver?.capture(input.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
input,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(input.sessionID))
}
}
}
describe("experimental.session.compacting handler", () => {
//#given all three hooks are present
//#when compacting handler is invoked
//#then all hooks are called in order: capture → PreCompact → contextInjector
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
const callOrder: string[] = []
const handler = createCompactingHandler({
compactionContextInjector: {
capture: mock(async () => {
callOrder.push("checkpointCapture")
}),
inject: mock((sessionID: string) => {
callOrder.push("contextInjector")
return `context-for-${sessionID}`
}),
},
compactionTodoPreserver: {
capture: mock(async () => {
callOrder.push("capture")
}),
},
claudeCodeHooks: {
"experimental.session.compacting": mock(async () => {
callOrder.push("preCompact")
}),
},
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(callOrder).toEqual([
"checkpointCapture",
"capture",
"preCompact",
"contextInjector",
])
expect(output.context).toEqual(["context-for-ses_test"])
})
//#given claudeCodeHooks injects context during PreCompact
//#when compacting handler is invoked
//#then injected context from PreCompact is preserved in output
it("preserves context injected by PreCompact hooks", async () => {
const handler = createCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": async (_input, output) => {
output.context.push("precompact-injected-context")
},
},
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(output.context).toContain("precompact-injected-context")
})
//#given claudeCodeHooks is null (no claude code hooks configured)
//#when compacting handler is invoked
//#then handler completes without error and other hooks still run
it("handles null claudeCodeHooks gracefully", async () => {
const captureMock = mock(async () => {})
const checkpointCaptureMock = mock(async () => {})
const contextMock = mock(() => "injected-context")
const handler = createCompactingHandler({
compactionContextInjector: {
capture: checkpointCaptureMock,
inject: contextMock,
},
compactionTodoPreserver: { capture: captureMock },
claudeCodeHooks: undefined,
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
expect(captureMock).toHaveBeenCalledWith("ses_test")
expect(contextMock).toHaveBeenCalledWith("ses_test")
expect(output.context).toEqual(["injected-context"])
})
//#given compactionContextInjector is null
//#when compacting handler is invoked
//#then handler does not early-return, PreCompact hooks still execute
it("does not early-return when compactionContextInjector is null", async () => {
const preCompactMock = mock(async () => {})
const handler = createCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": preCompactMock,
},
compactionContextInjector: undefined,
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toEqual([])
})
})
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "bun:test"
describe("look_at tool conditional registration", () => {
describe("isMultimodalLookerEnabled logic", () => {
// given multimodal-looker is in disabled_agents
// when checking if agent is enabled
// then should return false (disabled)
it("returns false when multimodal-looker is disabled (exact case)", () => {
const disabledAgents: string[] = ["multimodal-looker"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker",
)
expect(isEnabled).toBe(false)
})
// given multimodal-looker is in disabled_agents with different case
// 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: string[] = ["Multimodal-Looker"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker",
)
expect(isEnabled).toBe(false)
})
// given multimodal-looker is NOT in disabled_agents
// when checking if agent is enabled
// then should return true (enabled)
it("returns true when multimodal-looker is not disabled", () => {
const disabledAgents: string[] = ["oracle", "librarian"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker",
)
expect(isEnabled).toBe(true)
})
// given disabled_agents is empty
// when checking if agent is enabled
// then should return true (enabled by default)
it("returns true when disabled_agents is empty", () => {
const disabledAgents: string[] = []
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker",
)
expect(isEnabled).toBe(true)
})
// given disabled_agents is undefined (simulated as empty array)
// 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: string[] | undefined = undefined
const list: string[] = disabledAgents ?? []
const isEnabled = !list.some(
(agent) => agent.toLowerCase() === "multimodal-looker",
)
expect(isEnabled).toBe(true)
})
})
describe("conditional tool spread pattern", () => {
// given lookAt is not null (agent enabled)
// when spreading into tool object
// then look_at should be included
it("includes look_at when lookAt is not null", () => {
const lookAt = { execute: () => {} }
const tools = {
...(lookAt ? { look_at: lookAt } : {}),
}
expect(tools).toHaveProperty("look_at")
})
// given lookAt is null (agent disabled)
// when spreading into tool object
// then look_at should NOT be included
it("excludes look_at when lookAt is null", () => {
const lookAt = null
const tools = {
...(lookAt ? { look_at: lookAt } : {}),
}
expect(tools).not.toHaveProperty("look_at")
})
})
})
+5 -8
View File
@@ -29,7 +29,6 @@ const mockCreateHooks = mock(() => ({
compactionTodoPreserver: undefined,
claudeCodeHooks: undefined,
}))
const mockCreatePluginDispose = mock(() => async () => {})
const mockCreatePluginInterface = mock(() => ({}))
const mockCreatePluginPostHog = mock(() => ({
trackActive: () => {
@@ -70,9 +69,6 @@ function installModuleMocks(): void {
mock.module("./create-hooks", () => ({
createHooks: mockCreateHooks,
}))
mock.module("./plugin-dispose", () => ({
createPluginDispose: mockCreatePluginDispose,
}))
mock.module("./plugin-interface", () => ({
createPluginInterface: mockCreatePluginInterface,
}))
@@ -110,7 +106,7 @@ function installModuleMocks(): void {
}))
}
describe("OhMyOpenCodePlugin telemetry isolation", () => {
describe("oh-my-openagent telemetry isolation", () => {
beforeEach(() => {
mock.restore()
installModuleMocks()
@@ -125,12 +121,13 @@ describe("OhMyOpenCodePlugin telemetry isolation", () => {
const { default: plugin } = await import(`./index?telemetry=${Date.now()}-${Math.random()}`)
// when
const result = await plugin({
const result = await plugin.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof plugin>[0])
} as Parameters<typeof plugin.server>[0])
// then
expect(result).toMatchObject({ name: "oh-my-openagent" })
expect(typeof result).toBe("object")
expect(result).not.toBeNull()
})
})
+16 -231
View File
@@ -1,223 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
describe("experimental.session.compacting handler", () => {
function createCompactingHandler(hooks: {
compactionContextInjector?: {
capture: (sessionID: string) => Promise<void>
inject: (sessionID: string) => string
}
compactionTodoPreserver?: { capture: (sessionID: string) => Promise<void> }
claudeCodeHooks?: {
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[] },
) => Promise<void>
}
}) {
return async (
_input: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(_input.sessionID)
await hooks.compactionTodoPreserver?.capture(_input.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
_input,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(_input.sessionID))
}
}
}
//#given all three hooks are present
//#when compacting handler is invoked
//#then all hooks are called in order: capture → PreCompact → contextInjector
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
const callOrder: string[] = []
const handler = createCompactingHandler({
compactionContextInjector: {
capture: mock(async () => {
callOrder.push("checkpointCapture")
}),
inject: mock((sessionID: string) => {
callOrder.push("contextInjector")
return `context-for-${sessionID}`
}),
},
compactionTodoPreserver: {
capture: mock(async () => { callOrder.push("capture") }),
},
claudeCodeHooks: {
"experimental.session.compacting": mock(async () => {
callOrder.push("preCompact")
}),
},
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(callOrder).toEqual(["checkpointCapture", "capture", "preCompact", "contextInjector"])
expect(output.context).toEqual(["context-for-ses_test"])
})
//#given claudeCodeHooks injects context during PreCompact
//#when compacting handler is invoked
//#then injected context from PreCompact is preserved in output
it("preserves context injected by PreCompact hooks", async () => {
const handler = createCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": async (_input, output) => {
output.context.push("precompact-injected-context")
},
},
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(output.context).toContain("precompact-injected-context")
})
//#given claudeCodeHooks is null (no claude code hooks configured)
//#when compacting handler is invoked
//#then handler completes without error and other hooks still run
it("handles null claudeCodeHooks gracefully", async () => {
const captureMock = mock(async () => {})
const checkpointCaptureMock = mock(async () => {})
const contextMock = mock(() => "injected-context")
const handler = createCompactingHandler({
compactionContextInjector: {
capture: checkpointCaptureMock,
inject: contextMock,
},
compactionTodoPreserver: { capture: captureMock },
claudeCodeHooks: undefined,
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
expect(captureMock).toHaveBeenCalledWith("ses_test")
expect(contextMock).toHaveBeenCalledWith("ses_test")
expect(output.context).toEqual(["injected-context"])
})
//#given compactionContextInjector is null
//#when compacting handler is invoked
//#then handler does not early-return, PreCompact hooks still execute
it("does not early-return when compactionContextInjector is null", async () => {
const preCompactMock = mock(async () => {})
const handler = createCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": preCompactMock,
},
compactionContextInjector: undefined,
})
const output = { context: [] as string[] }
await handler({ sessionID: "ses_test" }, output)
expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toEqual([])
})
})
/**
* Tests for conditional tool registration logic in index.ts
*
* The actual plugin initialization is complex to test directly,
* so we test the underlying logic that determines tool registration.
*/
describe("look_at tool conditional registration", () => {
describe("isMultimodalLookerEnabled logic", () => {
// given multimodal-looker is in disabled_agents
// when checking if agent is enabled
// then should return false (disabled)
it("returns false when multimodal-looker is disabled (exact case)", () => {
const disabledAgents: string[] = ["multimodal-looker"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(false)
})
// given multimodal-looker is in disabled_agents with different case
// 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: string[] = ["Multimodal-Looker"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(false)
})
// given multimodal-looker is NOT in disabled_agents
// when checking if agent is enabled
// then should return true (enabled)
it("returns true when multimodal-looker is not disabled", () => {
const disabledAgents: string[] = ["oracle", "librarian"]
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(true)
})
// given disabled_agents is empty
// when checking if agent is enabled
// then should return true (enabled by default)
it("returns true when disabled_agents is empty", () => {
const disabledAgents: string[] = []
const isEnabled = !disabledAgents.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(true)
})
// given disabled_agents is undefined (simulated as empty array)
// 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: string[] | undefined = undefined
const list: string[] = disabledAgents ?? []
const isEnabled = !list.some(
(agent) => agent.toLowerCase() === "multimodal-looker"
)
expect(isEnabled).toBe(true)
})
})
describe("conditional tool spread pattern", () => {
// given lookAt is not null (agent enabled)
// when spreading into tool object
// then look_at should be included
it("includes look_at when lookAt is not null", () => {
const lookAt = { execute: () => {} } // mock tool
const tools = {
...(lookAt ? { look_at: lookAt } : {}),
}
expect(tools).toHaveProperty("look_at")
})
// given lookAt is null (agent disabled)
// when spreading into tool object
// then look_at should NOT be included
it("excludes look_at when lookAt is null", () => {
const lookAt = null
const tools = {
...(lookAt ? { look_at: lookAt } : {}),
}
expect(tools).not.toHaveProperty("look_at")
})
})
})
const mockInitConfigContext = mock(() => {})
const mockDetectExternalSkillPlugin = mock(() => ({ detected: false, pluginName: null }))
const mockGetSkillPluginConflictWarning = mock(() => "")
@@ -252,12 +34,11 @@ const mockCreateHooks = mock(() => ({
compactionTodoPreserver: undefined,
claudeCodeHooks: undefined,
}))
const mockCreatePluginDispose = mock(() => async () => {})
const mockCreatePluginInterface = mock(() => ({}))
const mockInitializeOpenClaw = mock(async () => {})
const mockStartTmuxCheck = mock(() => {})
let OhMyOpenCodePlugin: (typeof import("./index"))["default"]
let pluginModule: (typeof import("./index"))["default"]
function installIndexModuleMocks(): void {
mock.module("./cli/config-manager/config-context", () => ({
@@ -297,10 +78,6 @@ function installIndexModuleMocks(): void {
createHooks: mockCreateHooks,
}))
mock.module("./plugin-dispose", () => ({
createPluginDispose: mockCreatePluginDispose,
}))
mock.module("./plugin-interface", () => ({
createPluginInterface: mockCreatePluginInterface,
}))
@@ -333,11 +110,11 @@ async function importFreshIndexModule(): Promise<typeof import("./index")> {
return import(`./index?test=${Date.now()}-${Math.random()}`)
}
describe("OhMyOpenCodePlugin", () => {
describe("oh-my-openagent plugin module", () => {
beforeEach(async () => {
mock.restore()
installIndexModuleMocks()
;({ default: OhMyOpenCodePlugin } = await importFreshIndexModule())
;({ default: pluginModule } = await importFreshIndexModule())
mockInitConfigContext.mockClear()
mockDetectExternalSkillPlugin.mockClear()
mockGetSkillPluginConflictWarning.mockClear()
@@ -350,7 +127,6 @@ describe("OhMyOpenCodePlugin", () => {
mockCreateManagers.mockClear()
mockCreateTools.mockClear()
mockCreateHooks.mockClear()
mockCreatePluginDispose.mockClear()
mockCreatePluginInterface.mockClear()
mockInitializeOpenClaw.mockClear()
mockStartTmuxCheck.mockClear()
@@ -375,10 +151,10 @@ describe("OhMyOpenCodePlugin", () => {
})
// when
await OhMyOpenCodePlugin({
await pluginModule.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof OhMyOpenCodePlugin>[0])
} as Parameters<typeof pluginModule.server>[0])
// then
expect(mockInitializeOpenClaw).toHaveBeenCalledTimes(1)
@@ -390,12 +166,21 @@ describe("OhMyOpenCodePlugin", () => {
mockLoadPluginConfig.mockReturnValue({})
// when
await OhMyOpenCodePlugin({
await pluginModule.server({
directory: "/tmp/project",
client: {},
} as Parameters<typeof OhMyOpenCodePlugin>[0])
} as Parameters<typeof pluginModule.server>[0])
// then
expect(mockInitializeOpenClaw).not.toHaveBeenCalled()
})
it("exports a V1 PluginModule shape with id and server", () => {
// given the plugin module is loaded
// when inspecting the default export
// then it has the expected V1 shape
expect(typeof pluginModule).toBe("object")
expect(pluginModule.id).toBe("oh-my-openagent")
expect(typeof pluginModule.server).toBe("function")
})
})
+23 -32
View File
@@ -1,5 +1,5 @@
import { initConfigContext } from "./cli/config-manager/config-context"
import type { Plugin } from "@opencode-ai/plugin"
import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin"
import type { HookName } from "./config"
@@ -9,7 +9,6 @@ import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runt
import { createTools } from "./create-tools"
import { initializeOpenClaw } from "./openclaw"
import { createPluginInterface } from "./plugin-interface"
import { createPluginDispose, type PluginDispose } from "./plugin-dispose"
import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
@@ -17,27 +16,23 @@ import { createFirstMessageVariantGate } from "./shared/first-message-variant"
import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from "./shared"
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
import { lspManager } from "./tools/lsp/client"
import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog"
let activePluginDispose: PluginDispose | null = null
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
initConfigContext("opencode", null)
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", {
directory: ctx.directory,
log("[oh-my-openagent] ENTRY - plugin loading", {
directory: input.directory,
})
logLegacyPluginStartupWarning()
const skillPluginCheck = detectExternalSkillPlugin(ctx.directory)
const skillPluginCheck = detectExternalSkillPlugin(input.directory)
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
console.warn(getSkillPluginConflictWarning(skillPluginCheck.pluginName))
}
injectServerAuthIntoClient(ctx.client)
await activePluginDispose?.()
injectServerAuthIntoClient(input.client)
const pluginConfig = loadPluginConfig(ctx.directory, ctx)
const pluginConfig = loadPluginConfig(input.directory, input)
const posthog = createPluginPostHog()
const distinctId = getPostHogDistinctId()
@@ -78,7 +73,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const modelCacheState = createModelCacheState()
const managers = createManagers({
ctx,
ctx: input,
pluginConfig,
tmuxConfig,
modelCacheState,
@@ -86,31 +81,25 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
})
const toolsResult = await createTools({
ctx,
ctx: input,
pluginConfig,
managers,
})
const hooks = createHooks({
ctx,
ctx: input,
pluginConfig,
modelCacheState,
backgroundManager: managers.backgroundManager,
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
mergedSkills: toolsResult.mergedSkills,
availableSkills: toolsResult.availableSkills,
})
const dispose = createPluginDispose({
backgroundManager: managers.backgroundManager,
skillMcpManager: managers.skillMcpManager,
lspManager,
disposeHooks: hooks.disposeHooks,
})
const pluginInterface = createPluginInterface({
ctx,
ctx: input,
pluginConfig,
firstMessageVariantGate,
managers,
@@ -118,30 +107,32 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
tools: toolsResult.filteredTools,
})
activePluginDispose = dispose
return {
name: "oh-my-openagent",
...pluginInterface,
"experimental.session.compacting": async (
_input: { sessionID: string },
compactingInput: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(_input.sessionID)
await hooks.compactionTodoPreserver?.capture(_input.sessionID)
await hooks.compactionContextInjector?.capture(compactingInput.sessionID)
await hooks.compactionTodoPreserver?.capture(compactingInput.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
_input,
compactingInput,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(_input.sessionID))
output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID))
}
},
}
}
export default OhMyOpenCodePlugin
const pluginModule: PluginModule = {
id: "oh-my-openagent",
server: serverPlugin,
}
export default pluginModule
export type {
OhMyOpenCodeConfig,
-237
View File
@@ -1,237 +0,0 @@
import { describe, expect, spyOn, test } from "bun:test"
import { disposeCreatedHooks } from "./create-hooks"
import { createPluginDispose } from "./plugin-dispose"
describe("createPluginDispose", () => {
test("#given plugin with active managers and hooks #when dispose() is called #then backgroundManager.shutdown() is called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
})
test("#given plugin with active MCP connections #when dispose() is called #then skillMcpManager.disconnectAll() is called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
})
test("#given plugin with hooks that have dispose #when dispose() is called #then each hook's dispose is called", async () => {
// given
const claudeCodeHooks = {
dispose: (): void => {},
}
const commentChecker = {
dispose: (): void => {},
}
const runtimeFallback = {
dispose: (): void => {},
}
const todoContinuationEnforcer = {
dispose: (): void => {},
}
const autoSlashCommand = {
dispose: (): void => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const claudeCodeHooksDisposeSpy = spyOn(claudeCodeHooks, "dispose")
const commentCheckerDisposeSpy = spyOn(commentChecker, "dispose")
const runtimeFallbackDisposeSpy = spyOn(runtimeFallback, "dispose")
const todoContinuationEnforcerDisposeSpy = spyOn(todoContinuationEnforcer, "dispose")
const autoSlashCommandDisposeSpy = spyOn(autoSlashCommand, "dispose")
const dispose = createPluginDispose({
backgroundManager: {
shutdown: async (): Promise<void> => {},
},
skillMcpManager: {
disconnectAll: async (): Promise<void> => {},
},
lspManager,
disposeHooks: (): void => {
disposeCreatedHooks({
claudeCodeHooks,
commentChecker,
runtimeFallback,
todoContinuationEnforcer,
autoSlashCommand,
})
},
})
// when
await dispose()
// then
expect(claudeCodeHooksDisposeSpy).toHaveBeenCalledTimes(1)
expect(commentCheckerDisposeSpy).toHaveBeenCalledTimes(1)
expect(runtimeFallbackDisposeSpy).toHaveBeenCalledTimes(1)
expect(todoContinuationEnforcerDisposeSpy).toHaveBeenCalledTimes(1)
expect(autoSlashCommandDisposeSpy).toHaveBeenCalledTimes(1)
})
test("#given dispose already called #when dispose() called again #then no errors", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disposeHooks = {
run: (): void => {},
}
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const stopAllSpy = spyOn(lspManager, "stopAll")
const disposeHooksSpy = spyOn(disposeHooks, "run")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: disposeHooks.run,
})
// when
await dispose()
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
expect(stopAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksSpy).toHaveBeenCalledTimes(1)
})
test("#given backgroundManager.shutdown() throws #when dispose() is called #then skillMcpManager.disconnectAll() and disposeHooks() are still called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {
throw new Error("shutdown failed")
},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disposeHooksCalls: number[] = []
const disconnectAllSpy = spyOn(skillMcpManager, "disconnectAll")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
})
// when
await dispose()
// then
expect(disconnectAllSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
test("#given skillMcpManager.disconnectAll() throws #when dispose() is called #then disposeHooks() is still called", async () => {
// given
const backgroundManager = {
shutdown: async (): Promise<void> => {},
}
const skillMcpManager = {
disconnectAll: async (): Promise<void> => {
throw new Error("disconnectAll failed")
},
}
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const disposeHooksCalls: number[] = []
const shutdownSpy = spyOn(backgroundManager, "shutdown")
const dispose = createPluginDispose({
backgroundManager,
skillMcpManager,
lspManager,
disposeHooks: (): void => {
disposeHooksCalls.push(1)
},
})
// when
await dispose()
// then
expect(shutdownSpy).toHaveBeenCalledTimes(1)
expect(disposeHooksCalls).toHaveLength(1)
})
test("#given active LSP clients #when dispose runs #then lsp manager is stopped", async () => {
// given
const lspManager = {
stopAll: async (): Promise<void> => {},
}
const stopAllSpy = spyOn(lspManager, "stopAll")
const dispose = createPluginDispose({
backgroundManager: {
shutdown: async (): Promise<void> => {},
},
skillMcpManager: {
disconnectAll: async (): Promise<void> => {},
},
lspManager,
disposeHooks: (): void => {},
})
// when
await dispose()
// then
expect(stopAllSpy).toHaveBeenCalledTimes(1)
})
})
-51
View File
@@ -1,51 +0,0 @@
import { log } from "./shared"
export type PluginDispose = () => Promise<void>
export function createPluginDispose(args: {
backgroundManager: {
shutdown: () => void | Promise<void>
}
skillMcpManager: {
disconnectAll: () => Promise<void>
}
lspManager: {
stopAll: () => Promise<void>
}
disposeHooks: () => void
}): PluginDispose {
const { backgroundManager, skillMcpManager, lspManager, disposeHooks } = args
let disposePromise: Promise<void> | null = null
return async (): Promise<void> => {
if (disposePromise) {
await disposePromise
return
}
disposePromise = (async (): Promise<void> => {
try {
await backgroundManager.shutdown()
} catch (error) {
log("[plugin-dispose] backgroundManager.shutdown() error:", error)
}
try {
await skillMcpManager.disconnectAll()
} catch (error) {
log("[plugin-dispose] skillMcpManager.disconnectAll() error:", error)
}
try {
await lspManager.stopAll()
} catch (error) {
log("[plugin-dispose] lspManager.stopAll() error:", error)
}
try {
disposeHooks()
} catch (error) {
log("[plugin-dispose] disposeHooks() error:", error)
}
})()
await disposePromise
}
}
+10 -11
View File
@@ -65,13 +65,13 @@ function createChatMessageHandlerHooks(modelFallback: ReturnType<typeof createMo
let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined
let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined
afterEach(() => {
readConnectedProvidersCacheSpy?.mockRestore()
readProviderModelsCacheSpy?.mockRestore()
readConnectedProvidersCacheSpy = undefined
readProviderModelsCacheSpy = undefined
_resetForTesting()
})
afterEach(() => {
readConnectedProvidersCacheSpy?.mockRestore()
readProviderModelsCacheSpy?.mockRestore()
readConnectedProvidersCacheSpy = undefined
readProviderModelsCacheSpy = undefined
_resetForTesting()
})
describe("createEventHandler - category runtime fallback suppression", () => {
test("does not arm retry fallback when category session explicitly stores no fallback chain [regression #2941]", async () => {
@@ -83,11 +83,10 @@ describe("createEventHandler - category runtime fallback suppression", () => {
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
clearPendingModelFallback(sessionID)
setSessionAgent(sessionID, "sisyphus-junior")
setSessionFallbackChain(sessionID, undefined)
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
setSessionAgent(sessionID, "sisyphus-junior")
setSessionFallbackChain(modelFallback, sessionID, undefined)
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({
directory: "/tmp",
+5 -8
View File
@@ -142,9 +142,8 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_status_retry_fallback"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
@@ -232,8 +231,8 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_status_retry_dedup"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
await handler({
@@ -293,8 +292,8 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_status_retry_runtime_enabled"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
const runtimeFallback = {
event: async () => {},
"chat.message": async () => {},
@@ -346,9 +345,8 @@ describe("createEventHandler - model fallback", () => {
//#given
const sessionID = "ses_status_retry_user_fallback"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
const pluginConfig = {
agents: {
sisyphus: {
@@ -446,9 +444,8 @@ describe("createEventHandler - model fallback", () => {
const toastCalls: string[] = []
const sessionID = "ses_main_fallback_chain"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
setupConnectedProviderCacheMocks()
const eventHandler = createEventHandler({
+1 -2
View File
@@ -761,11 +761,10 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
//#given
const sessionID = "ses_retry_recovery_rearm"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const abortCalls: string[] = []
const promptCalls: string[] = []
const modelFallback = createModelFallbackHook()
clearPendingModelFallback(modelFallback, sessionID)
const eventHandler = createEventHandler({
ctx: asEventHandlerContext({
+22 -9
View File
@@ -15,6 +15,7 @@ import {
clearSessionFallbackChain,
setSessionFallbackChain,
setPendingModelFallback,
type ModelFallbackHook,
} from "../hooks/model-fallback/hook";
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
import {
@@ -111,6 +112,7 @@ function extractProviderModelFromErrorMessage(message: string): { providerID?: s
return {};
}
function applyUserConfiguredFallbackChain(
modelFallback: Pick<ModelFallbackHook, "setSessionFallbackChain"> | null | undefined,
sessionID: string,
agentName: string,
currentProviderID: string,
@@ -123,7 +125,9 @@ function applyUserConfiguredFallbackChain(
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
if (fallbackChain && fallbackChain.length > 0) {
setSessionFallbackChain(sessionID, fallbackChain);
if (modelFallback) {
setSessionFallbackChain(modelFallback, sessionID, fallbackChain);
}
}
}
@@ -170,6 +174,7 @@ export function createEventHandler(args: {
const isModelFallbackEnabled =
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
const modelFallback = hooks.modelFallback;
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>();
@@ -408,8 +413,10 @@ export function createEventHandler(args: {
lastHandledModelErrorMessageID.delete(sessionInfo.id);
lastHandledRetryStatusKey.delete(sessionInfo.id);
lastKnownModelBySession.delete(sessionInfo.id);
clearPendingModelFallback(sessionInfo.id);
clearSessionFallbackChain(sessionInfo.id);
if (modelFallback) {
clearPendingModelFallback(modelFallback, sessionInfo.id);
clearSessionFallbackChain(modelFallback, sessionInfo.id);
}
resetMessageCursor(sessionInfo.id);
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
@@ -517,9 +524,11 @@ export function createEventHandler(args: {
);
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7";
const currentModel = normalizeFallbackModelID(rawModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
const setFallback = modelFallback
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
: false;
if (
setFallback &&
@@ -580,9 +589,11 @@ export function createEventHandler(args: {
const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID);
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7";
currentModel = normalizeFallbackModelID(currentModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
const setFallback = modelFallback
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
: false;
if (
setFallback &&
@@ -666,9 +677,11 @@ export function createEventHandler(args: {
);
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7";
currentModel = normalizeFallbackModelID(currentModel);
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
const setFallback = modelFallback
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
: false;
if (
setFallback &&
@@ -9,7 +9,6 @@ import { createModelFallbackHook } from "../hooks/model-fallback/hook"
import { createRuntimeFallbackHook } from "../hooks/runtime-fallback"
import type { RuntimeFallbackPluginInput } from "../hooks/runtime-fallback/types"
import { _resetForTesting } from "../features/claude-code-session-state"
import { _resetForTesting as _resetModelFallbackForTesting } from "../hooks/model-fallback/hook"
import { SessionCategoryRegistry } from "../shared/session-category-registry"
import * as connectedProvidersCache from "../shared/connected-providers-cache"
@@ -369,7 +368,6 @@ function setupConnectedProviderCacheMocks(): void {
afterEach(() => {
_resetForTesting()
_resetModelFallbackForTesting()
SessionCategoryRegistry.clear()
})
+4 -1
View File
@@ -1,4 +1,5 @@
import type { HookName, OhMyOpenCodeConfig } from "../../config"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type { PluginContext } from "../types"
import type { ModelCacheState } from "../../plugin-state"
@@ -10,15 +11,17 @@ export function createCoreHooks(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState: ModelCacheState
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
}) {
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
const session = createSessionHooks({
ctx,
pluginConfig,
modelCacheState,
modelFallbackControllerAccessor,
isHookEnabled,
safeHookEnabled,
})
+4 -1
View File
@@ -1,4 +1,5 @@
import type { OhMyOpenCodeConfig, HookName } from "../../config"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type { ModelCacheState } from "../../plugin-state"
import type { PluginContext } from "../types"
@@ -69,10 +70,11 @@ export function createSessionHooks(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState: ModelCacheState
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
isHookEnabled: (hookName: HookName) => boolean
safeHookEnabled: boolean
}): SessionHooks {
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
@@ -171,6 +173,7 @@ export function createSessionHooks(args: {
.catch(() => {})
},
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
controllerAccessor: modelFallbackControllerAccessor,
}))
: null
+3 -1
View File
@@ -144,7 +144,7 @@ export function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): vo
export function createToolRegistry(args: {
ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
skillContext: SkillContext
availableCategories: AvailableCategory[]
interactiveBashEnabled?: boolean
@@ -170,6 +170,7 @@ export function createToolRegistry(args: {
pluginConfig.disabled_agents ?? [],
pluginConfig.agents,
pluginConfig.categories,
managers.modelFallbackControllerAccessor,
)
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
@@ -191,6 +192,7 @@ export function createToolRegistry(args: {
availableSkills: skillContext.availableSkills,
sisyphusAgentConfig: pluginConfig.sisyphus_agent,
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
onSyncSessionCreated: async (event) => {
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,
+1
View File
@@ -78,3 +78,4 @@ export * from "./plugin-identity"
export * from "./log-legacy-plugin-startup-warning"
export * from "./task-system-enabled"
export * from "./parse-tools-config"
export { parseModelString } from "./model-string-parser"
@@ -63,7 +63,7 @@ describe("logLegacyPluginStartupWarning", () => {
//#then
expect(mockLog).toHaveBeenCalledTimes(1)
expect(mockLog).toHaveBeenCalledWith(
"[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config",
"[legacy-migration] Legacy plugin entry detected in OpenCode config",
{
legacyEntries: ["oh-my-opencode", "oh-my-opencode@3.13.1"],
suggestedEntries: ["oh-my-openagent", "oh-my-openagent@3.13.1"],
@@ -22,7 +22,7 @@ export function logLegacyPluginStartupWarning(deps: LogLegacyPluginStartupWarnin
const suggestedEntries = result.legacyEntries.map(toCanonicalEntry)
logFn("[OhMyOpenCodePlugin] Legacy plugin entry detected in OpenCode config", {
logFn("[legacy-migration] Legacy plugin entry detected in OpenCode config", {
legacyEntries: result.legacyEntries,
suggestedEntries,
hasCanonicalEntry: result.hasCanonicalEntry,
@@ -41,13 +41,13 @@ export function parseModelString(
const trimmedModel = model.trim()
if (!trimmedModel) return undefined
const parts = trimmedModel.split("/")
if (parts.length < 2) {
const separatorIndex = trimmedModel.indexOf("/")
if (separatorIndex === -1) {
return undefined
}
const providerID = parts[0]?.trim()
const rawModelID = parts.slice(1).join("/").trim()
const providerID = trimmedModel.slice(0, separatorIndex).trim()
const rawModelID = trimmedModel.slice(separatorIndex + 1).trim()
if (!providerID || !rawModelID) {
return undefined
}
+114
View File
@@ -0,0 +1,114 @@
import { spawnSync } from "node:child_process"
import { existsSync } from "node:fs"
import { dirname, join } from "node:path"
import { downloadAndInstallRipgrep, getInstalledRipgrepPath } from "../tools/grep/downloader"
import { getDataDir } from "./data-path"
import { log } from "./logger"
import { PUBLISHED_PACKAGE_NAME } from "./plugin-identity"
export type GrepBackend = "rg" | "grep"
export interface ResolvedCli {
path: string
backend: GrepBackend
}
export const DEFAULT_RG_THREADS = 4
let cachedCli: ResolvedCli | null = null
let autoInstallAttempted = false
function findExecutable(name: string): string | null {
const isWindows = process.platform === "win32"
const cmd = isWindows ? "where" : "which"
try {
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim().split("\n")[0]
}
} catch {
return null
}
return null
}
function getOpenCodeBundledRg(): string | null {
const execPath = process.execPath
const execDir = dirname(execPath)
const isWindows = process.platform === "win32"
const rgName = isWindows ? "rg.exe" : "rg"
const candidates = [
join(getDataDir(), "opencode", "bin", rgName),
join(execDir, rgName),
join(execDir, "bin", rgName),
join(execDir, "..", "bin", rgName),
join(execDir, "..", "libexec", rgName),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
export function resolveGrepCli(): ResolvedCli {
if (cachedCli) {
return cachedCli
}
const rgPath = getOpenCodeBundledRg() ?? findExecutable("rg") ?? getInstalledRipgrepPath()
if (rgPath) {
cachedCli = { path: rgPath, backend: "rg" }
return cachedCli
}
const grep = findExecutable("grep")
if (grep) {
cachedCli = { path: grep, backend: "grep" }
return cachedCli
}
cachedCli = { path: "rg", backend: "rg" }
return cachedCli
}
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
const current = resolveGrepCli()
if (current.backend === "rg" && current.path !== "rg") {
return current
}
if (autoInstallAttempted) {
return current
}
autoInstallAttempted = true
try {
const rgPath = await downloadAndInstallRipgrep()
cachedCli = { path: rgPath, backend: "rg" }
return cachedCli
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (current.backend === "grep") {
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
error: message,
grep_path: current.path,
})
} else {
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
error: message,
})
}
return current
}
}
+4 -5
View File
@@ -1,7 +1,6 @@
import type { CallOmoAgentArgs } from "./types"
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { getAgentToolRestrictions, log } from "../../shared"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
@@ -19,8 +18,8 @@ type ExecuteSyncDeps = {
createOrGetSession: typeof createOrGetSession
waitForCompletion: typeof waitForCompletion
processMessages: typeof processMessages
setSessionFallbackChain: typeof setSessionFallbackChain
clearSessionFallbackChain: typeof clearSessionFallbackChain
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
clearSessionFallbackChain: (sessionID: string) => void
}
type SpawnReservation = {
@@ -32,8 +31,8 @@ const defaultDeps: ExecuteSyncDeps = {
createOrGetSession,
waitForCompletion,
processMessages,
setSessionFallbackChain,
clearSessionFallbackChain,
setSessionFallbackChain: () => {},
clearSessionFallbackChain: () => {},
}
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
+39 -4
View File
@@ -1,7 +1,8 @@
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
import type { CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { FallbackEntry } from "../../shared/model-requirements"
@@ -11,10 +12,27 @@ import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { log } from "../../shared"
import { CONFIG_BASENAME } from "../../shared/plugin-identity"
import { parseModelString } from "../delegate-task/model-string-parser"
import { parseModelString } from "../../shared"
import { executeBackground } from "./background-executor"
import { executeSync } from "./sync-executor"
import { resolveCallableAgents } from "./agent-resolver"
import { createOrGetSession } from "./session-creator"
import { processMessages } from "./message-processor"
import { waitForCompletion } from "./completion-poller"
function createSyncExecutorDeps(modelFallbackControllerAccessor?: ModelFallbackControllerAccessor) {
return {
createOrGetSession,
waitForCompletion,
processMessages,
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => {
modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
},
clearSessionFallbackChain: (sessionID: string) => {
modelFallbackControllerAccessor?.clearSessionFallbackChain(sessionID)
},
}
}
function resolveModelAndFallbackChain(args: {
subagentType: string
@@ -82,6 +100,7 @@ export function createCallOmoAgent(
disabledAgents: string[] = [],
agentOverrides?: AgentOverrides,
userCategories?: CategoriesConfig,
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor,
): ToolDefinition {
const agentDescriptions = ALLOWED_AGENTS.map(
(name) => `- ${name}: Specialized agent for ${name} tasks`,
@@ -158,14 +177,30 @@ export function createCallOmoAgent(
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
try {
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation, resolvedModel)
return await executeSync(
args,
toolCtx,
ctx,
createSyncExecutorDeps(modelFallbackControllerAccessor),
fallbackChain,
spawnReservation,
resolvedModel,
)
} catch (error) {
spawnReservation?.rollback()
return `Error: ${error instanceof Error ? error.message : String(error)}`
}
}
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel)
return await executeSync(
args,
toolCtx,
ctx,
createSyncExecutorDeps(modelFallbackControllerAccessor),
fallbackChain,
undefined,
resolvedModel,
)
},
});
}
+4 -3
View File
@@ -8,7 +8,6 @@ import { formatDetailedError } from "./error-formatting"
import { getSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
import { resolveMetadataModel } from "./resolve-metadata-model"
@@ -19,6 +18,7 @@ function continueSessionSetup(args: {
timing: ReturnType<typeof getTimingConfig>
fallbackChain?: FallbackEntry[]
category?: string
modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"]
}): void {
if (!args.fallbackChain && !args.category) {
return
@@ -41,7 +41,7 @@ function continueSessionSetup(args: {
continue
}
setSessionFallbackChain(sessionId, args.fallbackChain)
args.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, args.fallbackChain)
if (args.category) {
SessionCategoryRegistry.register(sessionId, args.category)
}
@@ -106,6 +106,7 @@ export async function executeBackgroundTask(
timing,
fallbackChain,
category: args.category,
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
})
break
}
@@ -113,7 +114,7 @@ export async function executeBackgroundTask(
}
if (sessionId) {
setSessionFallbackChain(sessionId, fallbackChain)
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, fallbackChain)
}
if (args.category && sessionId) {
SessionCategoryRegistry.register(sessionId, args.category)
+1 -1
View File
@@ -5,7 +5,7 @@ import type { FallbackEntry } from "../../shared/model-requirements"
import { mergeCategories } from "../../shared/merge-categories"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { resolveCategoryConfig } from "./categories"
import { parseModelString } from "./model-string-parser"
import { parseModelString } from "../../shared/model-string-parser"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { normalizeFallbackModels, flattenToFallbackModelStrings } from "../../shared/model-resolver"
import { buildFallbackChainFromModels, findMostSpecificFallbackEntry } from "../../shared/fallback-chain-from-models"
@@ -1,5 +1,6 @@
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type { OpencodeClient } from "./types"
export interface ExecutorContext {
@@ -12,6 +13,7 @@ export interface ExecutorContext {
browserProvider?: BrowserAutomationProvider
agentOverrides?: AgentOverrides
sisyphusAgentConfig?: SisyphusAgentConfig
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
syncPollTimeoutMs?: number
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { fuzzyMatchModel } from "../../shared/model-availability"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { hasConnectedProvidersCache, hasProviderModelsCache, readConnectedProvidersCache } from "../../shared/connected-providers-cache"
import { log } from "../../shared/logger"
import { parseModelString, parseVariantFromModelID } from "./model-string-parser"
import { parseModelString, parseVariantFromModelID } from "../../shared/model-string-parser"
function isExplicitHighModel(model: string): boolean {
return /(?:^|\/)[^/]+-high$/.test(model)
+2 -3
View File
@@ -9,7 +9,6 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { formatDuration } from "./time-formatter"
import { formatDetailedError } from "./error-formatting"
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
import { resolveMetadataModel } from "./resolve-metadata-model"
@@ -81,7 +80,7 @@ export async function executeSyncTask(
subagentSessions.add(sessionID)
syncSubagentSessions.add(sessionID)
setSessionAgent(sessionID, agentToUse)
setSessionFallbackChain(sessionID, fallbackChain)
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
if (args.category) {
SessionCategoryRegistry.register(sessionID, args.category)
@@ -237,7 +236,7 @@ ${buildTaskMetadataBlock({
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
syncSubagentSessions.delete(syncSessionID)
clearSessionFallbackChain(syncSessionID)
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
SessionCategoryRegistry.remove(syncSessionID)
}
}
@@ -0,0 +1,80 @@
import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { log } from "../../shared/logger"
export async function prepareDelegateTaskArgs(args: Record<string, unknown>, ctx: ToolContextWithMetadata): Promise<DelegateTaskArgs> {
const category = typeof args.category === "string" ? args.category : undefined
const prompt = typeof args.prompt === "string" ? args.prompt : ""
const originalSubagentType = typeof args.subagent_type === "string" ? args.subagent_type : undefined
let subagentType = originalSubagentType
if (category) {
if (subagentType && subagentType !== SISYPHUS_JUNIOR_AGENT) {
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
category,
subagent_type: subagentType,
})
}
subagentType = SISYPHUS_JUNIOR_AGENT
}
let description = typeof args.description === "string" ? args.description : undefined
if (!description || description.trim() === "") {
const words = prompt.trim().split(/\s+/)
description = words.slice(0, 4).join(" ") || "Delegated task"
}
await ctx.metadata?.({
title: description,
})
const runInBackground = args.run_in_background
if (runInBackground === undefined) {
throw new Error("Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.")
}
let loadSkills = args.load_skills
if (typeof loadSkills === "string") {
try {
const parsed = JSON.parse(loadSkills)
loadSkills = Array.isArray(parsed) ? parsed : []
} catch {
loadSkills = []
}
}
if (loadSkills === undefined) {
throw new Error("Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed.")
}
if (loadSkills === null) {
throw new Error("Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.")
}
const normalizedLoadSkills = Array.isArray(loadSkills)
? loadSkills.filter((value): value is string => typeof value === "string")
: []
const taskID = typeof args.task_id === "string" ? args.task_id : undefined
const command = typeof args.command === "string" ? args.command : undefined
args.category = category
args.subagent_type = subagentType
args.description = description
args.prompt = prompt
args.run_in_background = runInBackground
args.task_id = taskID
args.command = command
args.load_skills = normalizedLoadSkills
return {
category,
subagent_type: subagentType,
description,
prompt,
run_in_background: runInBackground === true,
task_id: taskID,
command,
load_skills: normalizedLoadSkills,
}
}
@@ -0,0 +1,86 @@
import type { AvailableCategory, AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { mergeCategories } from "../../shared/merge-categories"
import { CATEGORY_DESCRIPTIONS } from "./constants"
import type { DelegateTaskToolOptions } from "./types"
export interface DelegateTaskPresentation {
availableCategories: AvailableCategory[]
availableSkills: AvailableSkill[]
categoryExamples: string
description: string
}
export function createDelegateTaskPresentation(options: DelegateTaskToolOptions): DelegateTaskPresentation {
const { userCategories } = options
const allCategories = mergeCategories(userCategories)
const categoryEntries = Object.entries(allCategories).map(([name, categoryConfig]) => ({
name,
categoryConfig,
description: userCategories?.[name]?.description || CATEGORY_DESCRIPTIONS[name],
}))
const categoryNames = categoryEntries.map(({ name }) => name)
const categoryExamples = categoryNames.join(", ")
const availableCategories: AvailableCategory[] = options.availableCategories
?? categoryEntries.map(({ name, categoryConfig, description }) => {
return {
name,
description: description || "General tasks",
model: categoryConfig.model,
}
})
const availableSkills: AvailableSkill[] = options.availableSkills ?? []
const categoryList = categoryEntries.map(({ name, description }) => {
return description ? ` - ${name}: ${description}` : ` - ${name}`
}).join("\n")
const description = `Spawn agent task with category-based or direct agent selection.
⚠️ CRITICAL: You MUST provide EITHER category OR subagent_type. Omitting BOTH will FAIL.
**COMMON MISTAKE (DO NOT DO THIS):**
\`\`\`
task(description="...", prompt="...", run_in_background=false) // ❌ FAILS - missing category AND subagent_type
\`\`\`
**CORRECT - Using category:**
\`\`\`
task(category="quick", load_skills=[], description="Fix type error", prompt="...", run_in_background=false)
\`\`\`
**CORRECT - Using subagent_type:**
\`\`\`
task(subagent_type="explore", load_skills=[], description="Find patterns", prompt="...", run_in_background=true)
\`\`\`
REQUIRED: Provide ONE of:
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
**DO NOT provide both.** If category is provided, subagent_type is ignored.
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
- category: Use predefined category → Spawns Sisyphus-Junior with category config
Available categories:
${categoryList}
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
- command: The command that triggered this task (optional, for slash command tracking).
**WHEN TO USE task_id:**
- Task failed/incomplete → task_id with "fix: [specific issue]"
- Need follow-up on previous result → task_id with additional question
- Multi-turn conversation with same agent → always task_id instead of new task
Prompts MUST be in English.`
return {
availableCategories,
availableSkills,
categoryExamples,
description,
}
}
+38 -137
View File
@@ -1,14 +1,7 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { DelegateTaskArgs, DelegatedModelConfig, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
import { CATEGORY_DESCRIPTIONS } from "./constants"
import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent"
import { mergeCategories } from "../../shared/merge-categories"
import type { DelegatedModelConfig, ToolContextWithMetadata, DelegateTaskToolOptions } from "./types"
import { log } from "../../shared/logger"
import { buildSystemContent } from "./prompt-builder"
import type {
AvailableCategory,
AvailableSkill,
} from "../../agents/dynamic-agent-prompt-builder"
import {
resolveSkillContent,
resolveParentContext,
@@ -20,133 +13,37 @@ import {
executeBackgroundTask,
executeSyncTask,
} from "./executor"
import { prepareDelegateTaskArgs } from "./tool-argument-preparation"
import { createDelegateTaskPresentation } from "./tool-description"
export { resolveCategoryConfig } from "./categories"
export type { SyncSessionCreatedEvent, DelegateTaskToolOptions, BuildSystemContentInput } from "./types"
export { buildSystemContent, buildTaskPrompt } from "./prompt-builder"
const delegateTaskArgsSchema = {
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
category: tool.schema.string().optional().describe("REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type."),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
task_id: tool.schema.string().optional().describe("Existing task to continue. Canonical resume identifier."),
command: tool.schema.string().optional().describe("The command that triggered this task"),
}
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
const { userCategories } = options
const allCategories = mergeCategories(userCategories)
const categoryNames = Object.keys(allCategories)
const categoryExamples = categoryNames.join(", ")
const availableCategories: AvailableCategory[] = options.availableCategories
?? Object.entries(allCategories).map(([name, categoryConfig]) => {
const userDesc = userCategories?.[name]?.description
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
const description = userDesc || builtinDesc || "General tasks"
return {
name,
description,
model: categoryConfig.model,
}
})
const availableSkills: AvailableSkill[] = options.availableSkills ?? []
const categoryList = categoryNames.map(name => {
const userDesc = userCategories?.[name]?.description
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
const desc = userDesc || builtinDesc
return desc ? ` - ${name}: ${desc}` : ` - ${name}`
}).join("\n")
const description = `Spawn agent task with category-based or direct agent selection.
⚠️ CRITICAL: You MUST provide EITHER category OR subagent_type. Omitting BOTH will FAIL.
**COMMON MISTAKE (DO NOT DO THIS):**
\`\`\`
task(description="...", prompt="...", run_in_background=false) // ❌ FAILS - missing category AND subagent_type
\`\`\`
**CORRECT - Using category:**
\`\`\`
task(category="quick", load_skills=[], description="Fix type error", prompt="...", run_in_background=false)
\`\`\`
**CORRECT - Using subagent_type:**
\`\`\`
task(subagent_type="explore", load_skills=[], description="Find patterns", prompt="...", run_in_background=true)
\`\`\`
REQUIRED: Provide ONE of:
- category: For task delegation (uses Sisyphus-Junior with category-optimized model)
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
**DO NOT provide both.** If category is provided, subagent_type is ignored.
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
- category: Use predefined category → Spawns Sisyphus-Junior with category config
Available categories:
${categoryList}
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
- task_id: Existing task to continue (from previous task output). Continues the same subagent session with FULL CONTEXT PRESERVED.
- command: The command that triggered this task (optional, for slash command tracking).
**WHEN TO USE task_id:**
- Task failed/incomplete → task_id with "fix: [specific issue]"
- Need follow-up on previous result → task_id with additional question
- Multi-turn conversation with same agent → always task_id instead of new task
Prompts MUST be in English.`
const { availableCategories, availableSkills, categoryExamples, description } = createDelegateTaskPresentation(options)
return tool({
description,
args: {
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed."),
description: tool.schema.string().optional().describe("Short task description (3-5 words). Auto-generated from prompt if omitted."),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type."),
task_id: tool.schema.string().optional().describe("Existing task to continue. Canonical resume identifier."),
command: tool.schema.string().optional().describe("The command that triggered this task"),
},
async execute(args: DelegateTaskArgs, toolContext) {
args: delegateTaskArgsSchema,
async execute(args, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
const delegateTaskArgs = await prepareDelegateTaskArgs(args, ctx)
if (args.category) {
if (args.subagent_type && args.subagent_type !== SISYPHUS_JUNIOR_AGENT) {
log("[task] category provided - overriding subagent_type to sisyphus-junior", {
category: args.category,
subagent_type: args.subagent_type,
})
}
args.subagent_type = SISYPHUS_JUNIOR_AGENT
}
// Auto-generate description from prompt when missing or empty
if (!args.description || typeof args.description !== "string" || args.description.trim() === "") {
const words = (args.prompt || "").trim().split(/\s+/)
args.description = words.slice(0, 4).join(" ") || "Delegated task"
}
await ctx.metadata?.({
title: args.description,
})
if (args.run_in_background === undefined) {
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Specify run_in_background=false for task delegation, or run_in_background=true for parallel exploration.`)
}
if (typeof args.load_skills === "string") {
try {
const parsed = JSON.parse(args.load_skills)
args.load_skills = Array.isArray(parsed) ? parsed : []
} catch {
args.load_skills = []
}
}
if (args.load_skills === undefined) {
throw new Error(`Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed.`)
}
if (args.load_skills === null) {
throw new Error(`Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed.`)
}
const runInBackground = delegateTaskArgs.run_in_background === true
const runInBackground = args.run_in_background === true
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(args.load_skills, {
const { content: skillContent, contents: skillContents, error: skillError } = await resolveSkillContent(delegateTaskArgs.load_skills, {
gitMasterConfig: options.gitMasterConfig,
browserProvider: options.browserProvider,
disabledSkills: options.disabledSkills,
@@ -158,14 +55,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const parentContext = await resolveParentContext(ctx, options.client)
if (args.task_id) {
if (delegateTaskArgs.task_id) {
if (runInBackground) {
return executeBackgroundContinuation(args, ctx, options, parentContext)
return executeBackgroundContinuation(delegateTaskArgs, ctx, options, parentContext)
}
return executeSyncContinuation(args, ctx, options, parentContext)
return executeSyncContinuation(delegateTaskArgs, ctx, options, parentContext)
}
if (!args.category && !args.subagent_type) {
if (!delegateTaskArgs.category && !delegateTaskArgs.subagent_type) {
return `Invalid arguments: Must provide either category or subagent_type.`
}
@@ -190,8 +87,8 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
let fallbackChain: import("../../shared/model-requirements").FallbackEntry[] | undefined
let maxPromptTokens: number | undefined
if (args.category) {
const resolution = await resolveCategoryExecution(args, options, inheritedModel, systemDefaultModel)
if (delegateTaskArgs.category) {
const resolution = await resolveCategoryExecution(delegateTaskArgs, options, inheritedModel, systemDefaultModel)
if (resolution.error) {
return resolution.error
}
@@ -204,14 +101,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
fallbackChain = resolution.fallbackChain
maxPromptTokens = resolution.maxPromptTokens
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
const isRunInBackgroundExplicitlyFalse = isExplicitSyncRun(delegateTaskArgs.run_in_background)
log("[task] unstable agent detection", {
category: args.category,
category: delegateTaskArgs.category,
actualModel,
isUnstableAgent,
run_in_background_value: args.run_in_background,
run_in_background_type: typeof args.run_in_background,
run_in_background_value: delegateTaskArgs.run_in_background,
run_in_background_type: typeof delegateTaskArgs.run_in_background,
isRunInBackgroundExplicitlyFalse,
willForceBackground: isUnstableAgent && isRunInBackgroundExplicitlyFalse,
})
@@ -227,10 +124,10 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
availableCategories,
availableSkills,
})
return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
return executeUnstableAgentTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel)
}
} else {
const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples)
const resolution = await resolveSubagentExecution(delegateTaskArgs, options, parentContext.agent, categoryExamples)
if (resolution.error) {
return resolution.error
}
@@ -251,10 +148,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
})
if (runInBackground) {
return executeBackgroundTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
return executeBackgroundTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, fallbackChain)
}
return executeSyncTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
return executeSyncTask(delegateTaskArgs, ctx, options, parentContext, agentToUse, categoryModel, systemContent, modelInfo, fallbackChain)
},
})
}
function isExplicitSyncRun(runInBackground: unknown): boolean {
return runInBackground === false || runInBackground === "false"
}
+2
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
import type {
AvailableCategory,
AvailableSkill,
@@ -68,6 +69,7 @@ export interface DelegateTaskToolOptions {
availableSkills?: AvailableSkill[]
agentOverrides?: AgentOverrides
sisyphusAgentConfig?: SisyphusAgentConfig
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
syncPollTimeoutMs?: number
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../grep/constants"
export { resolveGrepCli, resolveGrepCliWithAutoInstall, type GrepBackend, DEFAULT_RG_THREADS } from "../../shared/ripgrep-cli"
export const DEFAULT_TIMEOUT_MS = 60_000
export const DEFAULT_LIMIT = 100
+3 -1
View File
@@ -3,13 +3,15 @@ import {
resolveGrepCli,
type ResolvedCli,
type GrepBackend,
DEFAULT_RG_THREADS,
} from "../../shared/ripgrep-cli"
import {
DEFAULT_MAX_DEPTH,
DEFAULT_MAX_FILESIZE,
DEFAULT_MAX_COUNT,
DEFAULT_MAX_COLUMNS,
DEFAULT_TIMEOUT_MS,
DEFAULT_MAX_OUTPUT_BYTES,
DEFAULT_RG_THREADS,
RG_SAFETY_FLAGS,
GREP_SAFETY_FLAGS,
} from "./constants"
-124
View File
@@ -1,126 +1,3 @@
import { existsSync } from "node:fs"
import { join, dirname } from "node:path"
import { spawnSync } from "node:child_process"
import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
import { getDataDir } from "../../shared/data-path"
import { log } from "../../shared/logger"
import { PUBLISHED_PACKAGE_NAME } from "../../shared/plugin-identity"
export type GrepBackend = "rg" | "grep"
export interface ResolvedCli {
path: string
backend: GrepBackend
}
let cachedCli: ResolvedCli | null = null
let autoInstallAttempted = false
function findExecutable(name: string): string | null {
const isWindows = process.platform === "win32"
const cmd = isWindows ? "where" : "which"
try {
const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim().split("\n")[0]
}
} catch {
// Command execution failed
}
return null
}
function getOpenCodeBundledRg(): string | null {
const execPath = process.execPath
const execDir = dirname(execPath)
const isWindows = process.platform === "win32"
const rgName = isWindows ? "rg.exe" : "rg"
const candidates = [
// OpenCode XDG data path (highest priority - where OpenCode installs rg)
join(getDataDir(), "opencode", "bin", rgName),
// Legacy paths relative to execPath
join(execDir, rgName),
join(execDir, "bin", rgName),
join(execDir, "..", "bin", rgName),
join(execDir, "..", "libexec", rgName),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return null
}
export function resolveGrepCli(): ResolvedCli {
if (cachedCli) return cachedCli
const bundledRg = getOpenCodeBundledRg()
if (bundledRg) {
cachedCli = { path: bundledRg, backend: "rg" }
return cachedCli
}
const systemRg = findExecutable("rg")
if (systemRg) {
cachedCli = { path: systemRg, backend: "rg" }
return cachedCli
}
const installedRg = getInstalledRipgrepPath()
if (installedRg) {
cachedCli = { path: installedRg, backend: "rg" }
return cachedCli
}
const grep = findExecutable("grep")
if (grep) {
cachedCli = { path: grep, backend: "grep" }
return cachedCli
}
cachedCli = { path: "rg", backend: "rg" }
return cachedCli
}
export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
const current = resolveGrepCli()
if (current.backend === "rg" && current.path !== "rg") {
return current
}
if (autoInstallAttempted) {
return current
}
autoInstallAttempted = true
try {
const rgPath = await downloadAndInstallRipgrep()
cachedCli = { path: rgPath, backend: "rg" }
return cachedCli
} catch (error) {
if (current.backend === "grep") {
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep. Falling back to GNU grep.`, {
error: error instanceof Error ? error.message : String(error),
grep_path: current.path,
})
} else {
log(`[${PUBLISHED_PACKAGE_NAME}] Failed to auto-install ripgrep and GNU grep was not found.`, {
error: error instanceof Error ? error.message : String(error),
})
}
return current
}
}
export const DEFAULT_MAX_DEPTH = 20
export const DEFAULT_MAX_FILESIZE = "10M"
export const DEFAULT_MAX_COUNT = 500
@@ -128,7 +5,6 @@ export const DEFAULT_MAX_COLUMNS = 1000
export const DEFAULT_CONTEXT = 2
export const DEFAULT_TIMEOUT_MS = 60_000
export const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024
export const DEFAULT_RG_THREADS = 4
export const RG_SAFETY_FLAGS = [
"--no-follow",
+1 -1
View File
@@ -1,8 +1,8 @@
import { resolve } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { resolveGrepCliWithAutoInstall } from "../../shared/ripgrep-cli"
import { runRg, runRgCount } from "./cli"
import { resolveGrepCliWithAutoInstall } from "./constants"
import { formatGrepResult, formatCountResult } from "./result-formatter"
export function createGrepTools(ctx: PluginInput): Record<string, ToolDefinition> {
+151
View File
@@ -0,0 +1,151 @@
import { basename } from "node:path"
import { pathToFileURL } from "node:url"
import type { LookAtArgs } from "./types"
import {
extractBase64Data,
inferMimeTypeFromBase64,
inferMimeTypeFromFilePath,
} from "./mime-type-inference"
import {
needsConversion,
convertImageToJpeg,
convertBase64ImageToJpeg,
cleanupConvertedImage,
} from "./image-converter"
import { log } from "../../shared"
export interface LookAtFilePart {
type: "file"
mime: string
url: string
filename: string
}
export interface PreparedLookAtInput {
readonly filePart: LookAtFilePart
readonly isBase64Input: boolean
readonly sourceDescription: string
cleanup(): void
}
type PrepareLookAtInputResult =
| { ok: true; value: PreparedLookAtInput }
| { ok: false; error: string }
function getTemporaryConversionPath(error: unknown): string | null {
if (!(error instanceof Error)) {
return null
}
const temporaryOutputPath = Reflect.get(error, "temporaryOutputPath")
if (typeof temporaryOutputPath === "string" && temporaryOutputPath.length > 0) {
return temporaryOutputPath
}
const temporaryDirectory = Reflect.get(error, "temporaryDirectory")
if (typeof temporaryDirectory === "string" && temporaryDirectory.length > 0) {
return temporaryDirectory
}
return null
}
export function prepareLookAtInput(args: LookAtArgs): PrepareLookAtInputResult {
const imageData = args.image_data
const filePath = args.file_path
if (imageData) {
const mimeType = inferMimeTypeFromBase64(imageData)
let finalBase64Data = extractBase64Data(imageData)
let finalMimeType = mimeType
let tempFilesToCleanup: string[] = []
if (needsConversion(mimeType)) {
log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`)
try {
const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType)
finalBase64Data = base64
finalMimeType = "image/jpeg"
tempFilesToCleanup = tempFiles
log("[look_at] Base64 conversion successful")
} catch (conversionError) {
log(`[look_at] Base64 conversion failed: ${conversionError}`)
return {
ok: false,
error: `Error: Failed to convert Base64 image format. ${conversionError}`,
}
}
}
return {
ok: true,
value: {
isBase64Input: true,
sourceDescription: "clipboard/pasted image",
filePart: {
type: "file",
mime: finalMimeType,
url: `data:${finalMimeType};base64,${finalBase64Data}`,
filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`,
},
cleanup() {
for (const temporaryFile of tempFilesToCleanup) {
cleanupConvertedImage(temporaryFile)
}
},
},
}
}
if (filePath) {
let mimeType = inferMimeTypeFromFilePath(filePath)
let actualFilePath = filePath
let tempConversionPath: string | null = null
if (needsConversion(mimeType)) {
log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`)
try {
const convertedFilePath = convertImageToJpeg(filePath, mimeType)
tempConversionPath = convertedFilePath
actualFilePath = convertedFilePath
mimeType = "image/jpeg"
log(`[look_at] Conversion successful: ${convertedFilePath}`)
} catch (conversionError) {
const failedConversionPath = getTemporaryConversionPath(conversionError)
if (failedConversionPath) {
tempConversionPath = failedConversionPath
}
log(`[look_at] Conversion failed: ${conversionError}`)
return {
ok: false,
error: `Error: Failed to convert image format. ${conversionError}`,
}
}
}
return {
ok: true,
value: {
isBase64Input: false,
sourceDescription: filePath,
filePart: {
type: "file",
mime: mimeType,
url: pathToFileURL(actualFilePath).href,
filename: basename(actualFilePath),
},
cleanup() {
if (tempConversionPath) {
cleanupConvertedImage(tempConversionPath)
}
},
},
}
}
return {
ok: false,
error: "Error: Must provide either 'file_path' or 'image_data'.",
}
}
+18
View File
@@ -0,0 +1,18 @@
export const READ_ENABLED = false
export function buildLookAtPrompt(goal: string, isBase64Input: boolean): string {
const subjectNoun = isBase64Input ? "image" : "file"
const sourceClause = READ_ENABLED
? "Use the Read tool on the provided file path to load its contents, then analyze it."
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
return `Analyze the attached ${subjectNoun} and extract the requested information.
${sourceClause}
Goal: ${goal}
Provide ONLY the extracted information that matches the goal.
Be thorough on what was requested, concise on everything else.
If the requested information is not found, clearly state what is missing.`
}
+107
View File
@@ -0,0 +1,107 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
import { extractLatestAssistantText } from "./assistant-message-extractor"
import { MULTIMODAL_LOOKER_AGENT } from "./constants"
import { READ_ENABLED, buildLookAtPrompt } from "./look-at-prompt"
import type { LookAtFilePart } from "./look-at-input-preparer"
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
interface RunLookAtSessionInput {
ctx: PluginInput
toolContext: ToolContext
goal: string
filePart: LookAtFilePart
isBase64Input: boolean
}
export async function runLookAtSession({
ctx,
toolContext,
goal,
filePart,
isBase64Input,
}: RunLookAtSessionInput): Promise<string> {
const prompt = buildLookAtPrompt(goal, isBase64Input)
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
log(`[look_at] Creating session with parent: ${toolContext.sessionID}`)
const parentSession = await ctx.client.session.get({
path: { id: toolContext.sessionID },
}).catch(() => null)
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
const createResult = await ctx.client.session.create({
body: {
parentID: toolContext.sessionID,
title: `look_at: ${goal.substring(0, 50)}`,
},
query: { directory: parentDirectory },
})
if (createResult.error) {
log("[look_at] Session create error:", createResult.error)
const errorString = String(createResult.error)
if (errorString.toLowerCase().includes("unauthorized")) {
return `Error: Failed to create session (Unauthorized). This may be due to:
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
2. Provider authentication issues
3. Session permission inheritance problems
Try using a different provider or API key authentication.
Original error: ${createResult.error}`
}
return `Error: Failed to create session: ${createResult.error}`
}
const sessionID = createResult.data.id
log(`[look_at] Created session: ${sessionID}`)
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
try {
await promptSyncWithModelSuggestionRetry(ctx.client, {
path: { id: sessionID },
body: {
agent: MULTIMODAL_LOOKER_AGENT,
tools: {
task: false,
call_omo_agent: false,
look_at: false,
read: READ_ENABLED,
},
parts: [
{ type: "text", text: prompt },
filePart,
],
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
...(agentVariant ? { variant: agentVariant } : {}),
},
})
} catch (promptError) {
log("[look_at] Prompt error (ignored, will still fetch messages):", promptError)
}
log(`[look_at] Fetching messages from session ${sessionID}...`)
const messagesResult = await ctx.client.session.messages({
path: { id: sessionID },
})
if (messagesResult.error) {
log("[look_at] Messages error:", messagesResult.error)
return `Error: Failed to get messages: ${messagesResult.error}`
}
const messages = messagesResult.data
log(`[look_at] Got ${messages.length} messages`)
const responseText = extractLatestAssistantText(messages)
if (!responseText) {
log("[look_at] No assistant message found")
return "Error: No response from multimodal-looker agent"
}
log(`[look_at] Got response, length: ${responseText.length}`)
return responseText
}
+18 -209
View File
@@ -1,43 +1,11 @@
import { basename } from "node:path"
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 { LOOK_AT_DESCRIPTION } from "./constants"
import type { LookAtArgs } from "./types"
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
import { extractLatestAssistantText } from "./assistant-message-extractor"
import { log } from "../../shared"
import type { LookAtArgsWithAlias } from "./look-at-arguments"
import { normalizeArgs, validateArgs } from "./look-at-arguments"
import {
extractBase64Data,
inferMimeTypeFromBase64,
inferMimeTypeFromFilePath,
} from "./mime-type-inference"
import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata"
import {
needsConversion,
convertImageToJpeg,
convertBase64ImageToJpeg,
cleanupConvertedImage,
} from "./image-converter"
function getTemporaryConversionPath(error: unknown): string | null {
if (!(error instanceof Error)) {
return null
}
const temporaryOutputPath = Reflect.get(error, "temporaryOutputPath")
if (typeof temporaryOutputPath === "string" && temporaryOutputPath.length > 0) {
return temporaryOutputPath
}
const temporaryDirectory = Reflect.get(error, "temporaryDirectory")
if (typeof temporaryDirectory === "string" && temporaryDirectory.length > 0) {
return temporaryDirectory
}
return null
}
import { prepareLookAtInput } from "./look-at-input-preparer"
import { runLookAtSession } from "./look-at-session-runner"
export { normalizeArgs, validateArgs } from "./look-at-arguments"
@@ -57,188 +25,29 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
return validationError
}
const isBase64Input = Boolean(args.image_data)
const sourceDescription = isBase64Input ? "clipboard/pasted image" : args.file_path
const preparedInputResult = prepareLookAtInput(args)
if (!preparedInputResult.ok) {
return preparedInputResult.error
}
const preparedInput = preparedInputResult.value
const { isBase64Input, sourceDescription } = preparedInput
log(`[look_at] Analyzing ${sourceDescription}, goal: ${args.goal}`)
const imageData = args.image_data
const filePath = args.file_path
let mimeType: string
let filePart: { type: "file"; mime: string; url: string; filename: string }
let tempFilePath: string | null = null
let tempConversionPath: string | null = null
let tempFilesToCleanup: string[] = []
try {
if (imageData) {
mimeType = inferMimeTypeFromBase64(imageData)
let finalBase64Data = extractBase64Data(imageData)
let finalMimeType = mimeType
if (needsConversion(mimeType)) {
log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`)
try {
const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType)
finalBase64Data = base64
finalMimeType = "image/jpeg"
tempFilesToCleanup = tempFiles
log(`[look_at] Base64 conversion successful`)
} catch (conversionError) {
log(`[look_at] Base64 conversion failed: ${conversionError}`)
return `Error: Failed to convert Base64 image format. ${conversionError}`
}
}
filePart = {
type: "file",
mime: finalMimeType,
url: `data:${finalMimeType};base64,${finalBase64Data}`,
filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`,
}
} else if (filePath) {
mimeType = inferMimeTypeFromFilePath(filePath)
let actualFilePath = filePath
if (needsConversion(mimeType)) {
log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`)
try {
tempFilePath = convertImageToJpeg(filePath, mimeType)
tempConversionPath = tempFilePath
actualFilePath = tempFilePath
mimeType = "image/jpeg"
log(`[look_at] Conversion successful: ${tempFilePath}`)
} catch (conversionError) {
const failedConversionPath = getTemporaryConversionPath(conversionError)
if (failedConversionPath) {
tempConversionPath = failedConversionPath
}
log(`[look_at] Conversion failed: ${conversionError}`)
return `Error: Failed to convert image format. ${conversionError}`
}
}
filePart = {
type: "file",
mime: mimeType,
url: pathToFileURL(actualFilePath).href,
filename: basename(actualFilePath),
}
} else {
return "Error: Must provide either 'file_path' or 'image_data'."
}
const readEnabled = false
const subjectNoun = isBase64Input ? "image" : "file"
const sourceClause = readEnabled
? `Use the Read tool on the provided file path to load its contents, then analyze it.`
: `The ${subjectNoun} is already attached to this message. Analyze it directly from the attachment. Do NOT attempt to use the Read tool. The Read tool is disabled for this invocation and the ${subjectNoun} cannot be loaded by path.`
const prompt = `Analyze the attached ${subjectNoun} and extract the requested information.
${sourceClause}
Goal: ${args.goal}
Provide ONLY the extracted information that matches the goal.
Be thorough on what was requested, concise on everything else.
If the requested information is not found, clearly state what is missing.`
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
log(`[look_at] Creating session with parent: ${toolContext.sessionID}`)
const parentSession = await ctx.client.session.get({
path: { id: toolContext.sessionID },
}).catch(() => null)
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
const createResult = await ctx.client.session.create({
body: {
parentID: toolContext.sessionID,
title: `look_at: ${args.goal.substring(0, 50)}`,
},
query: { directory: parentDirectory },
})
if (createResult.error) {
log(`[look_at] Session create error:`, createResult.error)
const errorStr = String(createResult.error)
if (errorStr.toLowerCase().includes("unauthorized")) {
return `Error: Failed to create session (Unauthorized). This may be due to:
1. OAuth token restrictions (e.g., Claude Code credentials are restricted to Claude Code only)
2. Provider authentication issues
3. Session permission inheritance problems
Try using a different provider or API key authentication.
Original error: ${createResult.error}`
}
return `Error: Failed to create session: ${createResult.error}`
}
const sessionID = createResult.data.id
log(`[look_at] Created session: ${sessionID}`)
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
try {
await promptSyncWithModelSuggestionRetry(ctx.client, {
path: { id: sessionID },
body: {
agent: MULTIMODAL_LOOKER_AGENT,
tools: {
task: false,
call_omo_agent: false,
look_at: false,
read: readEnabled,
},
parts: [
{ type: "text", text: prompt },
filePart,
],
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
...(agentVariant ? { variant: agentVariant } : {}),
},
return await runLookAtSession({
ctx,
toolContext,
goal: args.goal,
filePart: preparedInput.filePart,
isBase64Input,
})
} catch (promptError) {
log(`[look_at] Prompt error (ignored, will still fetch messages):`, promptError)
}
log(`[look_at] Fetching messages from session ${sessionID}...`)
const messagesResult = await ctx.client.session.messages({
path: { id: sessionID },
})
if (messagesResult.error) {
log(`[look_at] Messages error:`, messagesResult.error)
return `Error: Failed to get messages: ${messagesResult.error}`
}
const messages = messagesResult.data
log(`[look_at] Got ${messages.length} messages`)
const responseText = extractLatestAssistantText(messages)
if (!responseText) {
log("[look_at] No assistant message found")
return "Error: No response from multimodal-looker agent"
}
log(`[look_at] Got response, length: ${responseText.length}`)
return responseText
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log(`[look_at] Unexpected error analyzing ${sourceDescription}:`, error)
return `Error: Failed to analyze ${sourceDescription}: ${errorMessage}`
} finally {
if (tempConversionPath) {
cleanupConvertedImage(tempConversionPath)
} else if (tempFilePath) {
cleanupConvertedImage(tempFilePath)
}
tempFilesToCleanup.forEach(file => {
cleanupConvertedImage(file)
})
preparedInput.cleanup()
}
},
})
@@ -0,0 +1,25 @@
export function parseSkillMcpArguments(
argsJson: string | Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!argsJson) return {}
if (typeof argsJson === "object" && argsJson !== null) {
return argsJson
}
try {
const jsonString = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
const parsed = JSON.parse(jsonString)
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Arguments must be a JSON object")
}
return parsed as Record<string, unknown>
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Invalid arguments JSON: ${errorMessage}\n\n` +
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
`Received: ${argsJson}`,
)
}
}
+2 -25
View File
@@ -1,6 +1,7 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants"
import { parseSkillMcpArguments } from "./parse-skill-mcp-arguments"
import type { SkillMcpArgs } from "./types"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
@@ -82,30 +83,6 @@ function formatBuiltinMcpHint(mcpName: string): string | null {
)
}
function parseArguments(argsJson: string | Record<string, unknown> | undefined): Record<string, unknown> {
if (!argsJson) return {}
if (typeof argsJson === "object" && argsJson !== null) {
return argsJson
}
try {
// Strip outer single quotes if present (common in LLM output)
const jsonStr = argsJson.startsWith("'") && argsJson.endsWith("'") ? argsJson.slice(1, -1) : argsJson
const parsed = JSON.parse(jsonStr)
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Arguments must be a JSON object")
}
return parsed as Record<string, unknown>
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Invalid arguments JSON: ${errorMessage}\n\n` +
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
`Received: ${argsJson}`,
)
}
}
export function applyGrepFilter(output: string, pattern: string | undefined): string {
if (!pattern) return output
try {
@@ -174,7 +151,7 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition
skillName: found.skill.name,
}
const parsedArgs = parseArguments(args.arguments)
const parsedArgs = parseSkillMcpArguments(args.arguments)
let output: string
switch (operation.type) {