diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 226a369fc..de23e39de 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -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": { diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index d7229b36d..834aede4f 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -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 diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 283d71a3f..5d656ad65 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -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(), diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 065e4ed21..ee49b20be 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -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" diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index d51fd2db7..fb5f32960 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -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) diff --git a/src/features/background-agent/task-history.ts b/src/features/background-agent/task-history.ts index 333340c62..e737a0526 100644 --- a/src/features/background-agent/task-history.ts +++ b/src/features/background-agent/task-history.ts @@ -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, diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts deleted file mode 100644 index 300a69887..000000000 --- a/src/hooks/hashline-edit-diff-enhancer/hook.ts +++ /dev/null @@ -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 } -type AfterInput = { tool: string; sessionID: string; callID: string } -type AfterOutput = { title: string; output: string; metadata: Record } - -const STALE_TIMEOUT_MS = 5 * 60 * 1000 - -const pendingCaptures = new Map() - -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 | undefined { - const path = args.path ?? args.filePath ?? args.file_path - return typeof path === "string" ? path : undefined -} - -async function captureOldContent(filePath: string): Promise { - 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 - }, - } -} diff --git a/src/hooks/hashline-edit-diff-enhancer/index.test.ts b/src/hooks/hashline-edit-diff-enhancer/index.test.ts deleted file mode 100644 index ee39442e0..000000000 --- a/src/hooks/hashline-edit-diff-enhancer/index.test.ts +++ /dev/null @@ -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) { - return { args } -} - -function makeAfterOutput(overrides?: Partial<{ title: string; output: string; metadata: Record }>) { - 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 - - 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(() => {}) - }) - }) -}) diff --git a/src/hooks/hashline-edit-diff-enhancer/index.ts b/src/hooks/hashline-edit-diff-enhancer/index.ts deleted file mode 100644 index 883bccfea..000000000 --- a/src/hooks/hashline-edit-diff-enhancer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createHashlineEditDiffEnhancerHook } from "./hook" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d744896b1..1785ec183 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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"; diff --git a/src/hooks/ralph-loop/ralph-loop-event-handler.ts b/src/hooks/ralph-loop/ralph-loop-event-handler.ts index 5e0e871d0..b0fa5ed71 100644 --- a/src/hooks/ralph-loop/ralph-loop-event-handler.ts +++ b/src/hooks/ralph-loop/ralph-loop-event-handler.ts @@ -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, { diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 0ae86bb8c..dda3a3b6e 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -9,6 +9,9 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" const SESSION_TTL_MS = 30 * 60 * 1000 +declare function setTimeout(callback: () => void | Promise, delay?: number): ReturnType +declare function clearTimeout(timeout: ReturnType): 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 + } + } } } diff --git a/src/hooks/runtime-fallback/constants.ts b/src/hooks/runtime-fallback/constants.ts index b8001b96c..62c736eae 100644 --- a/src/hooks/runtime-fallback/constants.ts +++ b/src/hooks/runtime-fallback/constants.ts @@ -11,7 +11,7 @@ import type { RuntimeFallbackConfig } from "../../config" */ export const DEFAULT_CONFIG: Required = { 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, diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts deleted file mode 100644 index 4e795018d..000000000 --- a/src/hooks/task-reminder/hook.ts +++ /dev/null @@ -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() - - 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) - }, - } -} diff --git a/src/hooks/task-reminder/index.test.ts b/src/hooks/task-reminder/index.test.ts deleted file mode 100644 index db43ac589..000000000 --- a/src/hooks/task-reminder/index.test.ts +++ /dev/null @@ -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 - - 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") - }) -}) diff --git a/src/hooks/task-reminder/index.ts b/src/hooks/task-reminder/index.ts deleted file mode 100644 index 194a4261a..000000000 --- a/src/hooks/task-reminder/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createTaskReminderHook } from "./hook"; diff --git a/src/hooks/think-mode/switcher.ts b/src/hooks/think-mode/switcher.ts index e4d567a84..8d88506d6 100644 --- a/src/hooks/think-mode/switcher.ts +++ b/src/hooks/think-mode/switcher.ts @@ -109,6 +109,9 @@ const HIGH_VARIANT_MAP: Record = { "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 = new Set(Object.values(HIGH_VARIANT_MAP)) diff --git a/src/hooks/write-existing-file-guard/index.test.ts b/src/hooks/write-existing-file-guard/index.test.ts index 322e5198c..a64a9059e 100644 --- a/src/hooks/write-existing-file-guard/index.test.ts +++ b/src/hooks/write-existing-file-guard/index.test.ts @@ -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({ diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 0431c8a4c..9404f7095 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -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"]); }); }); diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index e405ee825..cff6c97e2 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -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 }> - 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 () => { diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index e488d2da9..9f8435a37 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -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, }; } diff --git a/src/plugin/chat-message.ts b/src/plugin/chat-message.ts index 7acd9b37b..c1af58125 100644 --- a/src/plugin/chat-message.ts +++ b/src/plugin/chat-message.ts @@ -45,6 +45,24 @@ export function createChatMessageHandler(args: { output: ChatMessageHandlerOutput ) => Promise { const { ctx, pluginConfig, firstMessageVariantGate, hooks } = args + const pluginContext = ctx as { + client: { + tui: { + showToast: (input: { + body: { + title: string + message: string + variant: "warning" + duration: number + } + }) => Promise + } + } + } + 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) } } diff --git a/src/plugin/event.ts b/src/plugin/event.ts index d9e9849fc..34a255e96 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -105,6 +105,23 @@ export function createEventHandler(args: { hooks: CreatedHooks }): (input: EventInput) => Promise { const { ctx, firstMessageVariantGate, managers, hooks } = args + const pluginContext = ctx as { + directory: string + client: { + session: { + abort: (input: { path: { id: string } }) => Promise + prompt: (input: { + path: { id: string } + body: { parts: Array<{ type: "text"; text: string }> } + query: { directory: string } + }) => Promise + } + } + } + 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() @@ -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(() => {}) } } } diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 6057bf65a..656aef81b 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -56,8 +56,8 @@ export type SessionHooks = { sisyphusJuniorNotepad: ReturnType | null noSisyphusGpt: ReturnType | null noHephaestusNonGpt: ReturnType | null - questionLabelTruncator: ReturnType - taskResumeInfo: ReturnType + questionLabelTruncator: ReturnType | null + taskResumeInfo: ReturnType | null anthropicEffort: ReturnType | null runtimeFallback: ReturnType | 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()) diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 3cc4d517f..492dd17db 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -12,6 +12,7 @@ import { createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, createHashlineReadEnhancerHook, + createJsonErrorRecoveryHook, } from "../../hooks" import { getOpenCodeVersion, @@ -31,6 +32,7 @@ export type ToolGuardHooks = { tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null + jsonErrorRecovery: ReturnType | 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, } } diff --git a/src/plugin/hooks/create-transform-hooks.ts b/src/plugin/hooks/create-transform-hooks.ts index 816a55b70..6a380118d 100644 --- a/src/plugin/hooks/create-transform-hooks.ts +++ b/src/plugin/hooks/create-transform-hooks.ts @@ -14,7 +14,7 @@ import { import { safeCreateHook } from "../../shared/safe-create-hook" export type TransformHooks = { - claudeCodeHooks: ReturnType + claudeCodeHooks: ReturnType | null keywordDetector: ReturnType | null contextInjectorMessagesTransform: ReturnType thinkingBlockValidator: ReturnType | 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( diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 31f20f593..fa6c8dade 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -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) } } diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index d6c6e63d4..0d90b4f16 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -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 } } } } diff --git a/src/tools/background-task/create-background-output.ts b/src/tools/background-task/create-background-output.ts index 0b63f2f50..6bb4e8093 100644 --- a/src/tools/background-task/create-background-output.ts +++ b/src/tools/background-task/create-background-output.ts @@ -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) diff --git a/src/tools/background-task/tools.test.ts b/src/tools/background-task/tools.test.ts index 79b084ce7..c28cf5a6f 100644 --- a/src/tools/background-task/tools.test.ts +++ b/src/tools/background-task/tools.test.ts @@ -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 () => { diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index e7aa20dc0..49ee2e2e7 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -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 } } : {}), diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index eec456f62..044776909 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -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 = { + 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 = { - 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) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index f4a9d1e2c..d06990036 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -76,8 +76,8 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] { })) return [ - ...userCommands, ...projectCommands, + ...userCommands, ...opencodeProjectCommands, ...opencodeGlobalCommands, ...builtinCommands,