fix: resolve publish blockers for v3.7.4→v3.8.0 release (#2014)

fix: resolve publish blockers for v3.7.4→v3.8.0 release
This commit is contained in:
YeonGyu-Kim
2026-02-21 16:43:19 +09:00
committed by GitHub
33 changed files with 179 additions and 770 deletions

View File

@@ -55,59 +55,7 @@
"disabled_hooks": {
"type": "array",
"items": {
"type": "string",
"enum": [
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
"session-notification",
"comment-checker",
"grep-output-truncator",
"tool-output-truncator",
"question-label-truncator",
"directory-agents-injector",
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"model-fallback",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",
"background-notification",
"auto-update-checker",
"startup-toast",
"keyword-detector",
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"thinking-block-validator",
"beast-mode-system",
"ralph-loop",
"category-skill-reminder",
"compaction-context-injector",
"compaction-todo-preserver",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"json-error-recovery",
"delegate-task-retry",
"prometheus-md-only",
"sisyphus-junior-notepad",
"no-sisyphus-gpt",
"no-hephaestus-non-gpt",
"start-work",
"atlas",
"unstable-agent-babysitter",
"task-reminder",
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
"runtime-fallback",
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
"hashline-edit-diff-enhancer"
]
"type": "string"
}
},
"disabled_commands": {

View File

@@ -6,7 +6,6 @@ export const HookNameSchema = z.enum([
"session-recovery",
"session-notification",
"comment-checker",
"grep-output-truncator",
"tool-output-truncator",
"question-label-truncator",
"directory-agents-injector",
@@ -44,7 +43,6 @@ export const HookNameSchema = z.enum([
"start-work",
"atlas",
"unstable-agent-babysitter",
"task-reminder",
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
@@ -52,7 +50,6 @@ export const HookNameSchema = z.enum([
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
"hashline-edit-diff-enhancer",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -11,7 +11,6 @@ import { CommentCheckerConfigSchema } from "./comment-checker"
import { BuiltinCommandNameSchema } from "./commands"
import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
import { HookNameSchema } from "./hooks"
import { NotificationConfigSchema } from "./notification"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
@@ -30,7 +29,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_hooks: z.array(z.string()).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
/** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */
disabled_tools: z.array(z.string()).optional(),

View File

@@ -29,6 +29,7 @@ import {
hasMoreFallbacks,
selectFallbackProvider,
} from "../../shared/model-error-classifier"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
@@ -364,7 +365,7 @@ export class BackgroundManager {
setSessionTools(sessionID, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch((error) => {
log("[background-agent] promptAsync error:", error)
@@ -637,7 +638,7 @@ export class BackgroundManager {
setSessionTools(existingTask.sessionID!, tools)
return tools
})(),
parts: [{ type: "text", text: input.prompt }],
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch((error) => {
log("[background-agent] resume prompt error:", error)
@@ -1006,9 +1007,10 @@ export class BackgroundManager {
}
task.attemptCount = selectedAttemptCount
const transformedModelId = transformModelForProvider(providerID, nextFallback.model)
task.model = {
providerID,
modelID: nextFallback.model,
modelID: transformedModelId,
variant: nextFallback.variant,
}
task.status = "pending"

View File

@@ -1,7 +1,7 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
@@ -146,7 +146,7 @@ export async function startTask(
question: false,
...getAgentToolRestrictions(input.agent),
},
parts: [{ type: "text", text: input.prompt }],
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch((error) => {
log("[background-agent] promptAsync error:", error)
@@ -230,7 +230,7 @@ export async function resumeTask(
question: false,
...getAgentToolRestrictions(task.agent),
},
parts: [{ type: "text", text: input.prompt }],
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch((error) => {
log("[background-agent] resume prompt error:", error)

View File

@@ -59,7 +59,7 @@ export class TaskHistory {
if (list.length === 0) return null
const lines = list.map((e) => {
const desc = e.description.replace(/[\n\r]+/g, " ").trim()
const desc = e.description?.replace(/[\n\r]+/g, " ").trim() ?? ""
const parts = [
`- **${e.agent}**`,
e.category ? `[${e.category}]` : null,

View File

@@ -1,106 +0,0 @@
import { log } from "../../shared"
import { generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
interface HashlineEditDiffEnhancerConfig {
hashline_edit?: { enabled: boolean }
}
type BeforeInput = { tool: string; sessionID: string; callID: string }
type BeforeOutput = { args: Record<string, unknown> }
type AfterInput = { tool: string; sessionID: string; callID: string }
type AfterOutput = { title: string; output: string; metadata: Record<string, unknown> }
const STALE_TIMEOUT_MS = 5 * 60 * 1000
const pendingCaptures = new Map<string, { content: string; filePath: string; storedAt: number }>()
function makeKey(sessionID: string, callID: string): string {
return `${sessionID}:${callID}`
}
function cleanupStaleEntries(): void {
const now = Date.now()
for (const [key, entry] of pendingCaptures) {
if (now - entry.storedAt > STALE_TIMEOUT_MS) {
pendingCaptures.delete(key)
}
}
}
function isWriteTool(toolName: string): boolean {
return toolName.toLowerCase() === "write"
}
function extractFilePath(args: Record<string, unknown>): string | undefined {
const path = args.path ?? args.filePath ?? args.file_path
return typeof path === "string" ? path : undefined
}
async function captureOldContent(filePath: string): Promise<string> {
try {
const file = Bun.file(filePath)
if (await file.exists()) {
return await file.text()
}
} catch {
log("[hashline-edit-diff-enhancer] failed to read old content", { filePath })
}
return ""
}
export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhancerConfig) {
const enabled = config.hashline_edit?.enabled ?? false
return {
"tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => {
if (!enabled || !isWriteTool(input.tool)) return
const filePath = extractFilePath(output.args)
if (!filePath) return
cleanupStaleEntries()
const oldContent = await captureOldContent(filePath)
pendingCaptures.set(makeKey(input.sessionID, input.callID), {
content: oldContent,
filePath,
storedAt: Date.now(),
})
},
"tool.execute.after": async (input: AfterInput, output: AfterOutput) => {
if (!enabled || !isWriteTool(input.tool)) return
const key = makeKey(input.sessionID, input.callID)
const captured = pendingCaptures.get(key)
if (!captured) return
pendingCaptures.delete(key)
const { content: oldContent, filePath } = captured
let newContent: string
try {
newContent = await Bun.file(filePath).text()
} catch {
log("[hashline-edit-diff-enhancer] failed to read new content", { filePath })
return
}
const { additions, deletions } = countLineDiffs(oldContent, newContent)
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath)
output.metadata.filediff = {
file: filePath,
path: filePath,
before: oldContent,
after: newContent,
additions,
deletions,
}
// TUI reads metadata.diff (unified diff string), not filediff object
output.metadata.diff = unifiedDiff
output.title = filePath
},
}
}

View File

@@ -1,306 +0,0 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createHashlineEditDiffEnhancerHook } from "./hook"
function makeInput(tool: string, callID = "call-1", sessionID = "ses-1") {
return { tool, sessionID, callID }
}
function makeBeforeOutput(args: Record<string, unknown>) {
return { args }
}
function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record<string, unknown> }>) {
return {
title: overrides?.title ?? "",
output: overrides?.output ?? "Successfully applied 1 edit(s)",
metadata: overrides?.metadata ?? { truncated: false },
}
}
type FileDiffMetadata = {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
describe("hashline-edit-diff-enhancer", () => {
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
beforeEach(() => {
hook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: true } })
})
describe("tool.execute.before", () => {
test("captures old file content for write tool", async () => {
const filePath = import.meta.dir + "/index.test.ts"
const input = makeInput("write")
const output = makeBeforeOutput({ path: filePath, edits: [] })
await hook["tool.execute.before"](input, output)
// given the hook ran without error, the old content should be stored internally
// we verify in the after hook test that it produces filediff
})
test("ignores non-write tools", async () => {
const input = makeInput("read")
const output = makeBeforeOutput({ path: "/some/file.ts" })
// when - should not throw
await hook["tool.execute.before"](input, output)
})
})
describe("tool.execute.after", () => {
test("injects filediff metadata after write tool execution", async () => {
// given - a temp file that we can modify between before/after
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-diff-1")
const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] })
// when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput)
// when - file is modified (simulating write execution)
const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n"
await Bun.write(tmpFile, newContent)
// when - after hook computes filediff
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
// then - metadata should contain filediff
const filediff = afterOutput.metadata.filediff as {
file: string
path: string
before: string
after: string
additions: number
deletions: number
}
expect(filediff).toBeDefined()
expect(filediff.file).toBe(tmpFile)
expect(filediff.path).toBe(tmpFile)
expect(filediff.before).toBe(oldContent)
expect(filediff.after).toBe(newContent)
expect(filediff.additions).toBeGreaterThan(0)
expect(filediff.deletions).toBeGreaterThan(0)
// then - title should be set to the file path
expect(afterOutput.title).toBe(tmpFile)
// cleanup
await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile)
})
test("does nothing for non-write tools", async () => {
const input = makeInput("read", "call-other")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
await hook["tool.execute.after"](input, afterOutput)
// then - metadata unchanged
expect(afterOutput.metadata).toEqual(originalMetadata)
})
test("does nothing when no before capture exists", async () => {
// given - no before hook was called for this callID
const input = makeInput("write", "call-no-before")
const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata }
await hook["tool.execute.after"](input, afterOutput)
// then - metadata unchanged (no filediff injected)
expect(afterOutput.metadata.filediff).toBeUndefined()
})
test("cleans up stored content after consumption", async () => {
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
await Bun.write(tmpFile, "original")
const input = makeInput("write", "call-cleanup")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
// when - first after call consumes
const afterOutput1 = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput1)
expect(afterOutput1.metadata.filediff).toBeDefined()
// when - second after call finds nothing
const afterOutput2 = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput2)
expect(afterOutput2.metadata.filediff).toBeUndefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
test("handles file creation (empty old content)", async () => {
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
// given - file doesn't exist during before hook
const input = makeInput("write", "call-create")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
// when - file created during write
await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
// then - filediff shows creation (before is empty)
const filediff = afterOutput.metadata.filediff as FileDiffMetadata
expect(filediff).toBeDefined()
expect(filediff.before).toBe("")
expect(filediff.after).toBe("new content\n")
expect(filediff.additions).toBeGreaterThan(0)
expect(filediff.deletions).toBe(0)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("disabled config", () => {
test("does nothing when hashline_edit is disabled", async () => {
const disabledHook = createHashlineEditDiffEnhancerHook({ hashline_edit: { enabled: false } })
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
await Bun.write(tmpFile, "content")
const input = makeInput("write", "call-disabled")
await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified")
const afterOutput = makeAfterOutput()
await disabledHook["tool.execute.after"](input, afterOutput)
// then - no filediff injected
expect(afterOutput.metadata.filediff).toBeUndefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("write tool support", () => {
test("captures filediff for write tool (path arg)", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-write-${Date.now()}.ts`
const oldContent = "line 1\nline 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-write-1")
const beforeOutput = makeBeforeOutput({ path: tmpFile })
//#when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput)
//#when - file is written
const newContent = "line 1\nmodified line 2\nnew line 3\n"
await Bun.write(tmpFile, newContent)
//#when - after hook computes filediff
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - metadata should contain filediff
const filediff = afterOutput.metadata.filediff as { file: string; before: string; after: string; additions: number; deletions: number }
expect(filediff).toBeDefined()
expect(filediff.file).toBe(tmpFile)
expect(filediff.additions).toBeGreaterThan(0)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
test("captures filediff for write tool (filePath arg)", async () => {
//#given
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-write-fp-${Date.now()}.ts`
await Bun.write(tmpFile, "original content\n")
const input = makeInput("write", "call-write-fp")
//#when - before hook uses filePath arg
await hook["tool.execute.before"](input, makeBeforeOutput({ filePath: tmpFile }))
await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then
const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined
expect(filediff).toBeDefined()
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("raw content in filediff", () => {
test("filediff.before and filediff.after are raw file content", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-format-${Date.now()}.ts`
const oldContent = "const x = 1\nconst y = 2\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-hashline-format")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified and after hook runs
const newContent = "const x = 1\nconst y = 42\n"
await Bun.write(tmpFile, newContent)
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - before and after should be raw file content
const filediff = afterOutput.metadata.filediff as { before: string; after: string }
expect(filediff.before).toBe(oldContent)
expect(filediff.after).toBe(newContent)
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
describe("TUI diff support (metadata.diff)", () => {
test("injects unified diff string in metadata.diff for write tool TUI", async () => {
//#given - a temp file
const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent)
const input = makeInput("write", "call-tui-diff")
await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified
const newContent = "line 1\nmodified line 2\nline 3\n"
await Bun.write(tmpFile, newContent)
const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput)
//#then - metadata.diff should be a unified diff string
expect(afterOutput.metadata.diff).toBeDefined()
expect(typeof afterOutput.metadata.diff).toBe("string")
expect(afterOutput.metadata.diff).toContain("---")
expect(afterOutput.metadata.diff).toContain("+++")
expect(afterOutput.metadata.diff).toContain("@@")
expect(afterOutput.metadata.diff).toContain("-line 2")
expect(afterOutput.metadata.diff).toContain("+modified line 2")
await (await import("fs/promises")).unlink(tmpFile).catch(() => {})
})
})
})

View File

@@ -1 +0,0 @@
export { createHashlineEditDiffEnhancerHook } from "./hook"

View File

@@ -49,6 +49,5 @@ export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallbackOptions } from "./runtime-fallback";
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
export { createBeastModeSystemHook, BEAST_MODE_SYSTEM_PROMPT } from "./beast-mode-system";
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";

View File

@@ -88,7 +88,7 @@ export function createRalphLoopEventHandler(
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
await ctx.client.tui.showToast({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
await ctx.client.tui?.showToast?.({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
return
}
@@ -100,11 +100,9 @@ export function createRalphLoopEventHandler(
})
options.loopState.clear()
await ctx.client.tui
.showToast({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
})
.catch(() => {})
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
return
}
@@ -120,16 +118,14 @@ export function createRalphLoopEventHandler(
max: newState.max_iterations,
})
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
})
.catch(() => {})
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
try {
await continueIteration(ctx, newState, {

View File

@@ -9,6 +9,9 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
const SESSION_TTL_MS = 30 * 60 * 1000
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): ReturnType<typeof globalThis.setTimeout>
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
export function createAutoRetryHelpers(deps: HookDeps) {
const { ctx, config, options, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts, pluginConfig } = deps
@@ -87,6 +90,10 @@ export function createAutoRetryHelpers(deps: HookDeps) {
const modelParts = newModel.split("/")
if (modelParts.length < 2) {
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
return
}
@@ -96,6 +103,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
}
sessionRetryInFlight.add(sessionID)
let retryDispatched = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
@@ -136,6 +144,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
},
query: { directory: ctx.directory },
})
retryDispatched = true
}
} else {
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
@@ -144,6 +153,14 @@ export function createAutoRetryHelpers(deps: HookDeps) {
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
} finally {
sessionRetryInFlight.delete(sessionID)
if (!retryDispatched) {
sessionAwaitingFallbackResult.delete(sessionID)
clearSessionFallbackTimeout(sessionID)
const state = sessionStates.get(sessionID)
if (state?.pendingFallbackModel) {
state.pendingFallbackModel = undefined
}
}
}
}

View File

@@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config"
*/
export const DEFAULT_CONFIG: Required<RuntimeFallbackConfig> = {
enabled: true,
retry_on_errors: [400, 429, 503, 529],
retry_on_errors: [429, 500, 502, 503, 504],
max_fallback_attempts: 3,
cooldown_seconds: 60,
timeout_seconds: 30,

View File

@@ -1,59 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
const TASK_TOOLS = new Set([
"task",
"task_create",
"task_list",
"task_get",
"task_update",
"task_delete",
])
const TURN_THRESHOLD = 10
const REMINDER_MESSAGE = `
The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.`
interface ToolExecuteInput {
tool: string
sessionID: string
callID: string
}
interface ToolExecuteOutput {
output: string
}
export function createTaskReminderHook(_ctx: PluginInput) {
const sessionCounters = new Map<string, number>()
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
const { tool, sessionID } = input
const toolLower = tool.toLowerCase()
if (TASK_TOOLS.has(toolLower)) {
sessionCounters.set(sessionID, 0)
return
}
const currentCount = sessionCounters.get(sessionID) ?? 0
const newCount = currentCount + 1
if (newCount >= TURN_THRESHOLD) {
output.output += REMINDER_MESSAGE
sessionCounters.set(sessionID, 0)
} else {
sessionCounters.set(sessionID, newCount)
}
}
return {
"tool.execute.after": toolExecuteAfter,
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type !== "session.deleted") return
const props = event.properties as { info?: { id?: string } } | undefined
const sessionId = props?.info?.id
if (!sessionId) return
sessionCounters.delete(sessionId)
},
}
}

View File

@@ -1,150 +0,0 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createTaskReminderHook } from "./index"
import type { PluginInput } from "@opencode-ai/plugin"
const mockCtx = {} as PluginInput
describe("TaskReminderHook", () => {
let hook: ReturnType<typeof createTaskReminderHook>
beforeEach(() => {
hook = createTaskReminderHook(mockCtx)
})
test("does not inject reminder before 10 turns", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
//#then
expect(output.output).not.toContain("task tools haven't been used")
})
test("injects reminder after 10 turns without task tool usage", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
//#then
expect(output.output).toContain("task tools haven't been used")
})
test("resets counter when task tool is used", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 5; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
await hook["tool.execute.after"]?.(
{ tool: "task", sessionID, callID: "call-task" },
output
)
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-after-${i}` },
output
)
}
//#then
expect(output.output).not.toContain("task tools haven't been used")
})
test("resets counter after injecting reminder", async () => {
//#given
const sessionID = "test-session"
const output1 = { output: "Result 1" }
const output2 = { output: "Result 2" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-1-${i}` },
output1
)
}
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-2-${i}` },
output2
)
}
//#then
expect(output1.output).toContain("task tools haven't been used")
expect(output2.output).not.toContain("task tools haven't been used")
})
test("tracks separate counters per session", async () => {
//#given
const session1 = "session-1"
const session2 = "session-2"
const output1 = { output: "Result 1" }
const output2 = { output: "Result 2" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID: session1, callID: `call-${i}` },
output1
)
}
for (let i = 0; i < 5; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID: session2, callID: `call-${i}` },
output2
)
}
//#then
expect(output1.output).toContain("task tools haven't been used")
expect(output2.output).not.toContain("task tools haven't been used")
})
test("cleans up counters on session.deleted", async () => {
//#given
const sessionID = "test-session"
const output = { output: "Result" }
//#when
for (let i = 0; i < 10; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-${i}` },
output
)
}
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
const outputAfterDelete = { output: "Result" }
for (let i = 0; i < 9; i++) {
await hook["tool.execute.after"]?.(
{ tool: "bash", sessionID, callID: `call-after-${i}` },
outputAfterDelete
)
}
//#then
expect(outputAfterDelete.output).not.toContain("task tools haven't been used")
})
})

View File

@@ -1 +0,0 @@
export { createTaskReminderHook } from "./hook";

View File

@@ -109,6 +109,9 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
"gpt-5-2": "gpt-5-2-high",
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
"gpt-5-2-pro": "gpt-5-2-pro-high",
// Antigravity (Google)
"antigravity-gemini-3-pro": "antigravity-gemini-3-pro-high",
"antigravity-gemini-3-flash": "antigravity-gemini-3-flash-high",
}
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))

View File

@@ -532,13 +532,16 @@ describe("createWriteExistingFileGuardHook", () => {
})
).resolves.toBeDefined()
// delete the session to trigger cleanup of any stored permissions/state
// read the file again to re-establish permission after first write consumed it
await invoke({
tool: "session.deleted",
tool: "read",
sessionID,
outputArgs: {},
outputArgs: { filePath: existingFile },
})
// delete the session to trigger cleanup of any stored permissions/state
await emitSessionDeleted(sessionID)
// after session deletion, the previous permissions must no longer apply
await expect(
invoke({

View File

@@ -180,7 +180,7 @@ describe("parseConfigPartially", () => {
expect(result).not.toBeNull();
expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2");
expect(result!.disabled_hooks).toBeUndefined();
expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]);
});
});
@@ -199,7 +199,7 @@ describe("parseConfigPartially", () => {
expect(result).not.toBeNull();
expect(result!.agents).toBeUndefined();
expect(result!.disabled_hooks).toBeUndefined();
expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]);
});
});

View File

@@ -1157,13 +1157,15 @@ describe("config-handler plugin loading error boundary (#1559)", () => {
})
describe("per-agent todowrite/todoread deny when task_system enabled", () => {
const PRIMARY_AGENTS = [
const AGENTS_WITH_TODO_DENY = new Set([
getAgentDisplayName("sisyphus"),
getAgentDisplayName("hephaestus"),
getAgentDisplayName("atlas"),
])
const AGENTS_WITHOUT_TODO_DENY = new Set([
getAgentDisplayName("prometheus"),
getAgentDisplayName("sisyphus-junior"),
]
])
test("denies todowrite and todoread for primary agents when task_system is enabled", async () => {
//#given
@@ -1200,10 +1202,14 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
//#then
const agentResult = config.agent as Record<string, { permission?: Record<string, unknown> }>
for (const agentName of PRIMARY_AGENTS) {
for (const agentName of AGENTS_WITH_TODO_DENY) {
expect(agentResult[agentName]?.permission?.todowrite).toBe("deny")
expect(agentResult[agentName]?.permission?.todoread).toBe("deny")
}
for (const agentName of AGENTS_WITHOUT_TODO_DENY) {
expect(agentResult[agentName]?.permission?.todowrite).toBeUndefined()
expect(agentResult[agentName]?.permission?.todoread).toBeUndefined()
}
})
test("does not deny todowrite/todoread when task_system is disabled", async () => {

View File

@@ -84,7 +84,6 @@ export function applyToolConfig(params: {
question: questionPermission,
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}
const junior = agentByKey(params.agentResult, "sisyphus-junior");
@@ -94,7 +93,6 @@ export function applyToolConfig(params: {
task: "allow",
"task_*": "allow",
teammate: "allow",
...denyTodoTools,
};
}

View File

@@ -45,6 +45,24 @@ export function createChatMessageHandler(args: {
output: ChatMessageHandlerOutput
) => Promise<void> {
const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args
const pluginContext = ctx as {
client: {
tui: {
showToast: (input: {
body: {
title: string
message: string
variant: "warning"
duration: number
}
}) => Promise<unknown>
}
}
}
const isRuntimeFallbackEnabled =
hooks.runtimeFallback !== null &&
hooks.runtimeFallback !== undefined &&
(pluginConfig.runtime_fallback?.enabled ?? true)
return async (
input: ChatMessageInput,
@@ -58,7 +76,9 @@ export function createChatMessageHandler(args: {
firstMessageVariantGate.markApplied(input.sessionID)
}
await hooks.modelFallback?.["chat.message"]?.(input, output)
if (!isRuntimeFallbackEnabled) {
await hooks.modelFallback?.["chat.message"]?.(input, output)
}
const modelOverride = output.message["model"]
if (
modelOverride &&
@@ -86,7 +106,7 @@ export function createChatMessageHandler(args: {
}
if (!hasConnectedProvidersCache()) {
ctx.client.tui
pluginContext.client.tui
.showToast({
body: {
title: "⚠️ Provider Cache Missing",
@@ -130,6 +150,6 @@ export function createChatMessageHandler(args: {
}
}
applyUltraworkModelOverrideOnMessage(pluginConfig, input.agent, output, ctx.client.tui, input.sessionID)
applyUltraworkModelOverrideOnMessage(pluginConfig, input.agent, output, pluginContext.client.tui, input.sessionID)
}
}

View File

@@ -105,6 +105,23 @@ export function createEventHandler(args: {
hooks: CreatedHooks
}): (input: EventInput) => Promise<void> {
const { ctx, firstMessageVariantGate, managers, hooks } = args
const pluginContext = ctx as {
directory: string
client: {
session: {
abort: (input: { path: { id: string } }) => Promise<unknown>
prompt: (input: {
path: { id: string }
body: { parts: Array<{ type: "text"; text: string }> }
query: { directory: string }
}) => Promise<unknown>
}
}
}
const isRuntimeFallbackEnabled =
hooks.runtimeFallback !== null &&
hooks.runtimeFallback !== undefined &&
(args.pluginConfig.runtime_fallback?.enabled ?? true)
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
const lastHandledModelErrorMessageID = new Map<string, string>()
@@ -250,7 +267,7 @@ export function createEventHandler(args: {
// Model fallback: in practice, API/model failures often surface as assistant message errors.
// session.error events are not guaranteed for all providers, so we also observe message.updated.
if (sessionID && role === "assistant") {
if (sessionID && role === "assistant" && !isRuntimeFallbackEnabled) {
try {
const assistantMessageID = info?.id as string | undefined
const assistantError = info?.error
@@ -292,12 +309,12 @@ export function createEventHandler(args: {
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
lastHandledModelErrorMessageID.set(sessionID, assistantMessageID)
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await ctx.client.session
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
query: { directory: pluginContext.directory },
})
.catch(() => {})
}
@@ -353,12 +370,12 @@ export function createEventHandler(args: {
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await ctx.client.session
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
query: { directory: pluginContext.directory },
})
.catch(() => {})
}
@@ -395,17 +412,17 @@ export function createEventHandler(args: {
sessionID === getMainSessionID() &&
!hooks.stopContinuationGuard?.isStopped(sessionID)
) {
await ctx.client.session
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
query: { directory: pluginContext.directory },
})
.catch(() => {})
}
}
// Second, try model fallback for model errors (rate limit, quota, provider issues, etc.)
else if (sessionID && shouldRetryError(errorInfo)) {
else if (sessionID && shouldRetryError(errorInfo) && !isRuntimeFallbackEnabled) {
let agentName = getSessionAgent(sessionID)
if (!agentName && sessionID === getMainSessionID()) {
@@ -432,15 +449,15 @@ export function createEventHandler(args: {
)
if (setFallback && shouldAutoRetrySession(sessionID) && !hooks.stopContinuationGuard?.isStopped(sessionID)) {
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
})
.catch(() => {})
await pluginContext.client.session.abort({ path: { id: sessionID } }).catch(() => {})
await pluginContext.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: pluginContext.directory },
})
.catch(() => {})
}
}
}

View File

@@ -56,8 +56,8 @@ export type SessionHooks = {
sisyphusJuniorNotepad: ReturnType<typeof createSisyphusJuniorNotepadHook> | null
noSisyphusGpt: ReturnType<typeof createNoSisyphusGptHook> | null
noHephaestusNonGpt: ReturnType<typeof createNoHephaestusNonGptHook> | null
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook>
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook>
questionLabelTruncator: ReturnType<typeof createQuestionLabelTruncatorHook> | null
taskResumeInfo: ReturnType<typeof createTaskResumeInfoHook> | null
anthropicEffort: ReturnType<typeof createAnthropicEffortHook> | null
runtimeFallback: ReturnType<typeof createRuntimeFallbackHook> | null
}
@@ -234,8 +234,12 @@ export function createSessionHooks(args: {
? safeHook("no-hephaestus-non-gpt", () => createNoHephaestusNonGptHook(ctx))
: null
const questionLabelTruncator = createQuestionLabelTruncatorHook()
const taskResumeInfo = createTaskResumeInfoHook()
const questionLabelTruncator = isHookEnabled("question-label-truncator")
? safeHook("question-label-truncator", () => createQuestionLabelTruncatorHook())
: null
const taskResumeInfo = isHookEnabled("task-resume-info")
? safeHook("task-resume-info", () => createTaskResumeInfoHook())
: null
const anthropicEffort = isHookEnabled("anthropic-effort")
? safeHook("anthropic-effort", () => createAnthropicEffortHook())

View File

@@ -12,6 +12,7 @@ import {
createTasksTodowriteDisablerHook,
createWriteExistingFileGuardHook,
createHashlineReadEnhancerHook,
createJsonErrorRecoveryHook,
} from "../../hooks"
import {
getOpenCodeVersion,
@@ -31,6 +32,7 @@ export type ToolGuardHooks = {
tasksTodowriteDisabler: ReturnType<typeof createTasksTodowriteDisablerHook> | null
writeExistingFileGuard: ReturnType<typeof createWriteExistingFileGuardHook> | null
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
}
export function createToolGuardHooks(args: {
@@ -99,6 +101,10 @@ export function createToolGuardHooks(args: {
? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } }))
: null
const jsonErrorRecovery = isHookEnabled("json-error-recovery")
? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx))
: null
return {
commentChecker,
toolOutputTruncator,
@@ -109,5 +115,6 @@ export function createToolGuardHooks(args: {
tasksTodowriteDisabler,
writeExistingFileGuard,
hashlineReadEnhancer,
jsonErrorRecovery,
}
}

View File

@@ -14,7 +14,7 @@ import {
import { safeCreateHook } from "../../shared/safe-create-hook"
export type TransformHooks = {
claudeCodeHooks: ReturnType<typeof createClaudeCodeHooksHook>
claudeCodeHooks: ReturnType<typeof createClaudeCodeHooksHook> | null
keywordDetector: ReturnType<typeof createKeywordDetectorHook> | null
contextInjectorMessagesTransform: ReturnType<typeof createContextInjectorMessagesTransformHook>
thinkingBlockValidator: ReturnType<typeof createThinkingBlockValidatorHook> | null
@@ -30,14 +30,21 @@ export function createTransformHooks(args: {
const { ctx, pluginConfig, isHookEnabled } = args
const safeHookEnabled = args.safeHookEnabled ?? true
const claudeCodeHooks = createClaudeCodeHooksHook(
ctx,
{
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
keywordDetectorDisabled: !isHookEnabled("keyword-detector"),
},
contextCollector,
)
const claudeCodeHooks = isHookEnabled("claude-code-hooks")
? safeCreateHook(
"claude-code-hooks",
() =>
createClaudeCodeHooksHook(
ctx,
{
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
keywordDetectorDisabled: !isHookEnabled("keyword-detector"),
},
contextCollector,
),
{ enabled: safeHookEnabled },
)
: null
const keywordDetector = isHookEnabled("keyword-detector")
? safeCreateHook(

View File

@@ -44,5 +44,6 @@ export function createToolExecuteAfterHandler(args: {
await hooks.atlasHook?.["tool.execute.after"]?.(input, output)
await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output)
await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output)
await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output)
}
}

View File

@@ -116,8 +116,10 @@ export function resolveModelPipeline(
if (parts.length >= 2) {
const provider = parts[0]
if (connectedSet.has(provider)) {
log("Model resolved via user fallback_models (connected provider)", { model })
return { model, provenance: "provider-fallback", attempted }
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
return { model: transformedModel, provenance: "provider-fallback", attempted }
}
}
}

View File

@@ -45,7 +45,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
"Wait for completion (default: false). System notifies when done, so blocking is rarely needed."
),
timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"),
full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"),
full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: true)"),
include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"),
message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"),
since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"),
@@ -78,7 +78,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
}
const isActive = task.status === "pending" || task.status === "running"
const fullSession = args.full_session ?? false
const fullSession = args.full_session ?? true
const includeThinking = isActive || (args.include_thinking ?? false)
const includeToolResults = isActive || (args.include_tool_results ?? false)

View File

@@ -243,8 +243,7 @@ describe("background_output full_session", () => {
const output = await tool.execute({ task_id: "task-1" }, mockContext)
// #then
expect(output).toContain("# Task Status")
expect(output).not.toContain("# Full Session Output")
expect(output).toContain("# Full Session Output")
})
test("returns full session when explicitly requested for running task", async () => {

View File

@@ -7,6 +7,7 @@ import {
import { formatDetailedError } from "./error-formatting"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
import { setSessionTools } from "../../shared/session-tools-store"
import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker"
type SendSyncPromptDeps = {
promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry
@@ -56,7 +57,7 @@ export async function sendSyncPrompt(
agent: input.agentToUse,
system: input.systemContent,
tools,
parts: [{ type: "text", text: input.args.prompt }],
parts: [createInternalAgentTextPart(input.args.prompt)],
...(input.categoryModel
? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } }
: {}),

View File

@@ -10,6 +10,15 @@ import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
import { discoverCommandsSync } from "../slashcommand/command-discovery"
import type { CommandInfo } from "../slashcommand/types"
import { formatLoadedCommand } from "../slashcommand/command-output-formatter"
// Priority: project > user > opencode/opencode-project > builtin/config
const scopePriority: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
config: 1,
builtin: 1,
}
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
@@ -31,15 +40,7 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
return TOOL_DESCRIPTION_NO_SKILLS
}
// Priority: project > user > opencode/opencode-project > builtin/config
const scopePriority: Record<string, number> = {
project: 4,
user: 3,
opencode: 2,
"opencode-project": 2,
config: 1,
builtin: 1,
}
// Uses module-level scopePriority for consistent priority ordering
const allItems: string[] = []
@@ -273,8 +274,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return output.join("\n")
}
// Check commands (exact match, case-insensitive)
const matchedCommand = commands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
// Check commands (exact match, case-insensitive) - sort by priority first
const sortedCommands = [...commands].sort((a, b) => {
const priorityA = scopePriority[a.scope] || 0
const priorityB = scopePriority[b.scope] || 0
return priorityB - priorityA // Higher priority first
})
const matchedCommand = sortedCommands.find(c => c.name.toLowerCase() === requestedName.toLowerCase())
if (matchedCommand) {
return await formatLoadedCommand(matchedCommand, args.user_message)

View File

@@ -76,8 +76,8 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] {
}))
return [
...userCommands,
...projectCommands,
...userCommands,
...opencodeProjectCommands,
...opencodeGlobalCommands,
...builtinCommands,