From ae19ff60cfb40867104918d60ff9fae4c138b7d9 Mon Sep 17 00:00:00 2001 From: XIN PENG Date: Mon, 16 Feb 2026 10:44:54 -0800 Subject: [PATCH 01/62] feat: Add automatic image format conversion for HEIC/RAW/PSD files Adds automatic conversion of unsupported image formats (HEIC, HEIF, RAW, PSD) to JPEG before sending to multimodal-looker agent. Changes: - Add image-converter.ts module with format detection and conversion - Modify look_at tool to auto-convert unsupported formats - Extend mime-type-inference.ts to support 15+ additional formats - Use sips (macOS) and ImageMagick (Linux/Windows) for conversion - Add proper cleanup of temporary files Fixes #722 Testing: - All existing tests pass (29/29) - TypeScript type checking passes - Verified HEIC to JPEG conversion on macOS --- src/tools/look-at/image-converter.ts | 114 +++++++++++++++++++++++ src/tools/look-at/mime-type-inference.ts | 17 ++++ src/tools/look-at/tools.ts | 37 +++++++- 3 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 src/tools/look-at/image-converter.ts diff --git a/src/tools/look-at/image-converter.ts b/src/tools/look-at/image-converter.ts new file mode 100644 index 000000000..af9aef2e3 --- /dev/null +++ b/src/tools/look-at/image-converter.ts @@ -0,0 +1,114 @@ +import { execSync } from "node:child_process" +import { existsSync, mkdtempSync, unlinkSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { log } from "../../shared" + +const SUPPORTED_FORMATS = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/bmp", + "image/tiff", +]) + +const UNSUPPORTED_FORMATS = new Set([ + "image/heic", + "image/heif", + "image/x-canon-cr2", + "image/x-canon-crw", + "image/x-nikon-nef", + "image/x-nikon-nrw", + "image/x-sony-arw", + "image/x-sony-sr2", + "image/x-sony-srf", + "image/x-pentax-pef", + "image/x-olympus-orf", + "image/x-panasonic-raw", + "image/x-fuji-raf", + "image/x-adobe-dng", + "image/vnd.adobe.photoshop", + "image/x-photoshop", +]) + +export function needsConversion(mimeType: string): boolean { + if (SUPPORTED_FORMATS.has(mimeType)) { + return false + } + + if (UNSUPPORTED_FORMATS.has(mimeType)) { + return true + } + + return mimeType.startsWith("image/") +} + +export function convertImageToJpeg(inputPath: string, mimeType: string): string { + if (!existsSync(inputPath)) { + throw new Error(`File not found: ${inputPath}`) + } + + const tempDir = mkdtempSync(join(tmpdir(), "opencode-img-")) + const outputPath = join(tempDir, "converted.jpg") + + log(`[image-converter] Converting ${mimeType} to JPEG: ${inputPath}`) + + try { + if (process.platform === "darwin") { + try { + execSync(`sips -s format jpeg "${inputPath}" --out "${outputPath}"`, { + stdio: "pipe", + encoding: "utf-8", + }) + + if (existsSync(outputPath)) { + log(`[image-converter] Converted using sips: ${outputPath}`) + return outputPath + } + } catch (sipsError) { + log(`[image-converter] sips failed: ${sipsError}`) + } + } + + try { + execSync(`convert "${inputPath}" "${outputPath}"`, { + stdio: "pipe", + encoding: "utf-8", + }) + + if (existsSync(outputPath)) { + log(`[image-converter] Converted using ImageMagick: ${outputPath}`) + return outputPath + } + } catch (convertError) { + log(`[image-converter] ImageMagick convert failed: ${convertError}`) + } + + throw new Error( + `No image conversion tool available. Please install ImageMagick:\n` + + ` macOS: brew install imagemagick\n` + + ` Ubuntu/Debian: sudo apt install imagemagick\n` + + ` RHEL/CentOS: sudo yum install ImageMagick` + ) + } catch (error) { + try { + if (existsSync(outputPath)) { + unlinkSync(outputPath) + } + } catch {} + + throw error + } +} + +export function cleanupConvertedImage(filePath: string): void { + try { + if (existsSync(filePath)) { + unlinkSync(filePath) + log(`[image-converter] Cleaned up temporary file: ${filePath}`) + } + } catch (error) { + log(`[image-converter] Failed to cleanup ${filePath}: ${error}`) + } +} diff --git a/src/tools/look-at/mime-type-inference.ts b/src/tools/look-at/mime-type-inference.ts index 18954c46c..3a7c0010a 100644 --- a/src/tools/look-at/mime-type-inference.ts +++ b/src/tools/look-at/mime-type-inference.ts @@ -29,8 +29,25 @@ export function inferMimeTypeFromFilePath(filePath: string): string { ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".tiff": "image/tiff", + ".tif": "image/tiff", ".heic": "image/heic", ".heif": "image/heif", + ".cr2": "image/x-canon-cr2", + ".crw": "image/x-canon-crw", + ".nef": "image/x-nikon-nef", + ".nrw": "image/x-nikon-nrw", + ".arw": "image/x-sony-arw", + ".sr2": "image/x-sony-sr2", + ".srf": "image/x-sony-srf", + ".pef": "image/x-pentax-pef", + ".orf": "image/x-olympus-orf", + ".raw": "image/x-panasonic-raw", + ".raf": "image/x-fuji-raf", + ".dng": "image/x-adobe-dng", + ".psd": "image/vnd.adobe.photoshop", ".mp4": "video/mp4", ".mpeg": "video/mpeg", ".mpg": "video/mpeg", diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 0d5c1c0b7..9980ed1b0 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -13,6 +13,11 @@ import { inferMimeTypeFromFilePath, } from "./mime-type-inference" import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata" +import { + needsConversion, + convertImageToJpeg, + cleanupConvertedImage, +} from "./image-converter" export { normalizeArgs, validateArgs } from "./look-at-arguments" @@ -41,8 +46,10 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { let mimeType: string let filePart: { type: "file"; mime: string; url: string; filename: string } + let tempFilePath: string | null = null - if (imageData) { + try { + if (imageData) { mimeType = inferMimeTypeFromBase64(imageData) filePart = { type: "file", @@ -52,11 +59,26 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { } } else if (filePath) { mimeType = inferMimeTypeFromFilePath(filePath) + + let actualFilePath = filePath + if (needsConversion(mimeType)) { + log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`) + try { + tempFilePath = convertImageToJpeg(filePath, mimeType) + actualFilePath = tempFilePath + mimeType = "image/jpeg" + log(`[look_at] Conversion successful: ${tempFilePath}`) + } catch (conversionError) { + log(`[look_at] Conversion failed: ${conversionError}`) + return `Error: Failed to convert image format. ${conversionError}` + } + } + filePart = { type: "file", mime: mimeType, - url: pathToFileURL(filePath).href, - filename: basename(filePath), + url: pathToFileURL(actualFilePath).href, + filename: basename(actualFilePath), } } else { return "Error: Must provide either 'file_path' or 'image_data'." @@ -149,8 +171,13 @@ Original error: ${createResult.error}` return "Error: No response from multimodal-looker agent" } - log(`[look_at] Got response, length: ${responseText.length}`) - return responseText + log(`[look_at] Got response, length: ${responseText.length}`) + return responseText + } finally { + if (tempFilePath) { + cleanupConvertedImage(tempFilePath) + } + } }, }) } From 116ca090e07139a9bb1d1fe4f9bfa92fc10b011f Mon Sep 17 00:00:00 2001 From: XIN PENG Date: Mon, 16 Feb 2026 11:08:25 -0800 Subject: [PATCH 02/62] fix: Add Base64 image format conversion support Extends conversion logic to handle Base64-encoded images (e.g., from clipboard). Previously, unsupported formats like HEIC/RAW/PSD in Base64 form bypassed the conversion check and caused failures at multimodal-looker agent. Changes: - Add convertBase64ImageToJpeg() function in image-converter.ts - Save Base64 data to temp file, convert, read back as Base64 - Update tools.ts to check and convert Base64 images when needed - Ensure proper cleanup of all temporary files Testing: - All tests pass (29/29) - Verified with 1.7MB HEIC file converted from Base64 - Type checking passes --- src/tools/look-at/image-converter.ts | 37 +++++++++++++++++++++++++++- src/tools/look-at/tools.ts | 37 ++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/tools/look-at/image-converter.ts b/src/tools/look-at/image-converter.ts index af9aef2e3..6718bd0fa 100644 --- a/src/tools/look-at/image-converter.ts +++ b/src/tools/look-at/image-converter.ts @@ -1,5 +1,5 @@ import { execSync } from "node:child_process" -import { existsSync, mkdtempSync, unlinkSync } from "node:fs" +import { existsSync, mkdtempSync, unlinkSync, writeFileSync, readFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { log } from "../../shared" @@ -112,3 +112,38 @@ export function cleanupConvertedImage(filePath: string): void { log(`[image-converter] Failed to cleanup ${filePath}: ${error}`) } } + +export function convertBase64ImageToJpeg( + base64Data: string, + mimeType: string +): { base64: string; tempFiles: string[] } { + const tempDir = mkdtempSync(join(tmpdir(), "opencode-b64-")) + const inputExt = mimeType.split("/")[1] || "bin" + const inputPath = join(tempDir, `input.${inputExt}`) + const tempFiles: string[] = [inputPath] + + try { + const cleanBase64 = base64Data.replace(/^data:[^;]+;base64,/, "") + const buffer = Buffer.from(cleanBase64, "base64") + writeFileSync(inputPath, buffer) + + log(`[image-converter] Converting Base64 ${mimeType} to JPEG`) + + const outputPath = convertImageToJpeg(inputPath, mimeType) + tempFiles.push(outputPath) + + const convertedBuffer = readFileSync(outputPath) + const convertedBase64 = convertedBuffer.toString("base64") + + log(`[image-converter] Base64 conversion successful`) + + return { base64: convertedBase64, tempFiles } + } catch (error) { + tempFiles.forEach(file => { + try { + if (existsSync(file)) unlinkSync(file) + } catch {} + }) + throw error + } +} diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 9980ed1b0..b76438609 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -16,6 +16,7 @@ import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadat import { needsConversion, convertImageToJpeg, + convertBase64ImageToJpeg, cleanupConvertedImage, } from "./image-converter" @@ -47,17 +48,36 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { let mimeType: string let filePart: { type: "file"; mime: string; url: string; filename: string } let tempFilePath: string | null = null + let tempFilesToCleanup: string[] = [] try { if (imageData) { - mimeType = inferMimeTypeFromBase64(imageData) - filePart = { - type: "file", - mime: mimeType, - url: `data:${mimeType};base64,${extractBase64Data(imageData)}`, - filename: `clipboard-image.${mimeType.split("/")[1] || "png"}`, - } - } else if (filePath) { + mimeType = inferMimeTypeFromBase64(imageData) + + let finalBase64Data = extractBase64Data(imageData) + let finalMimeType = mimeType + + if (needsConversion(mimeType)) { + log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`) + try { + const { base64, tempFiles } = convertBase64ImageToJpeg(imageData, mimeType) + finalBase64Data = base64 + finalMimeType = "image/jpeg" + tempFilesToCleanup = tempFiles + log(`[look_at] Base64 conversion successful`) + } catch (conversionError) { + log(`[look_at] Base64 conversion failed: ${conversionError}`) + return `Error: Failed to convert Base64 image format. ${conversionError}` + } + } + + filePart = { + type: "file", + mime: finalMimeType, + url: `data:${finalMimeType};base64,${finalBase64Data}`, + filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`, + } + } else if (filePath) { mimeType = inferMimeTypeFromFilePath(filePath) let actualFilePath = filePath @@ -177,6 +197,7 @@ Original error: ${createResult.error}` if (tempFilePath) { cleanupConvertedImage(tempFilePath) } + tempFilesToCleanup.forEach(file => cleanupConvertedImage(file)) } }, }) From ea814ffa15c551cb3a3ec48bb43d8cacd32992bc Mon Sep 17 00:00:00 2001 From: XIN PENG Date: Tue, 17 Feb 2026 08:14:40 -0800 Subject: [PATCH 03/62] fix: detect HEIC/HEIF from raw Base64 image signatures --- src/tools/look-at/mime-type-inference.test.ts | 36 +++++++++++++++++++ src/tools/look-at/mime-type-inference.ts | 8 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/tools/look-at/mime-type-inference.test.ts diff --git a/src/tools/look-at/mime-type-inference.test.ts b/src/tools/look-at/mime-type-inference.test.ts new file mode 100644 index 000000000..ed76e1b47 --- /dev/null +++ b/src/tools/look-at/mime-type-inference.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { extractBase64Data, inferMimeTypeFromBase64, inferMimeTypeFromFilePath } from "./mime-type-inference" + +describe("mime type inference", () => { + test("returns MIME from data URL prefix", () => { + const mime = inferMimeTypeFromBase64("data:image/heic;base64,AAAAGGZ0eXBoZWlj") + expect(mime).toBe("image/heic") + }) + + test("detects HEIC from raw base64 magic bytes", () => { + const heicHeader = Buffer.from("00000018667479706865696300000000", "hex").toString("base64") + const mime = inferMimeTypeFromBase64(heicHeader) + expect(mime).toBe("image/heic") + }) + + test("detects HEIF from raw base64 magic bytes", () => { + const heifHeader = Buffer.from("00000018667479706865696600000000", "hex").toString("base64") + const mime = inferMimeTypeFromBase64(heifHeader) + expect(mime).toBe("image/heif") + }) + + test("falls back to png when base64 signature is unknown", () => { + const mime = inferMimeTypeFromBase64("dW5rbm93biBiaW5hcnk=") + expect(mime).toBe("image/png") + }) + + test("infers heic from file extension", () => { + const mime = inferMimeTypeFromFilePath("/tmp/photo.HEIC") + expect(mime).toBe("image/heic") + }) + + test("extracts raw base64 data from data URL", () => { + const base64 = extractBase64Data("data:image/png;base64,abc123") + expect(base64).toBe("abc123") + }) +}) diff --git a/src/tools/look-at/mime-type-inference.ts b/src/tools/look-at/mime-type-inference.ts index 3a7c0010a..0718259de 100644 --- a/src/tools/look-at/mime-type-inference.ts +++ b/src/tools/look-at/mime-type-inference.ts @@ -8,12 +8,18 @@ export function inferMimeTypeFromBase64(base64Data: string): string { try { const cleanData = base64Data.replace(/^data:[^;]+;base64,/, "") - const header = atob(cleanData.slice(0, 16)) + const header = Buffer.from(cleanData.slice(0, 256), "base64").toString("binary") if (header.startsWith("\x89PNG")) return "image/png" if (header.startsWith("\xFF\xD8\xFF")) return "image/jpeg" if (header.startsWith("GIF8")) return "image/gif" if (header.startsWith("RIFF") && header.includes("WEBP")) return "image/webp" + if (header.includes("ftypheic") || header.includes("ftypheix") || header.includes("ftyphevc") || header.includes("ftyphevx")) { + return "image/heic" + } + if (header.includes("ftypheif") || header.includes("ftypmif1") || header.includes("ftypmsf1")) { + return "image/heif" + } if (header.startsWith("%PDF")) return "application/pdf" } catch { // invalid base64 - fall through From 814380b85c51500e2e4b6de63658c9f92b05f24f Mon Sep 17 00:00:00 2001 From: XIN PENG Date: Tue, 17 Feb 2026 08:21:07 -0800 Subject: [PATCH 04/62] fix: normalize Base64 data URL input before image conversion --- src/tools/look-at/mime-type-inference.test.ts | 5 +++++ src/tools/look-at/tools.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tools/look-at/mime-type-inference.test.ts b/src/tools/look-at/mime-type-inference.test.ts index ed76e1b47..69ac6e6bf 100644 --- a/src/tools/look-at/mime-type-inference.test.ts +++ b/src/tools/look-at/mime-type-inference.test.ts @@ -33,4 +33,9 @@ describe("mime type inference", () => { const base64 = extractBase64Data("data:image/png;base64,abc123") expect(base64).toBe("abc123") }) + + test("extracts raw base64 data from data URL with extra parameters", () => { + const base64 = extractBase64Data("data:image/heic;name=clip.heic;base64,abc123") + expect(base64).toBe("abc123") + }) }) diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index b76438609..a4c67f051 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -60,7 +60,7 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { if (needsConversion(mimeType)) { log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`) try { - const { base64, tempFiles } = convertBase64ImageToJpeg(imageData, mimeType) + const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType) finalBase64Data = base64 finalMimeType = "image/jpeg" tempFilesToCleanup = tempFiles From 479bbb240f257be9342e533d21770c58e3c01178 Mon Sep 17 00:00:00 2001 From: XIN PENG Date: Tue, 17 Feb 2026 08:58:41 -0800 Subject: [PATCH 05/62] fix: avoid shell interpolation in image conversion commands --- src/tools/look-at/image-converter.test.ts | 60 +++++++++++++++++++++++ src/tools/look-at/image-converter.ts | 6 +-- 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/tools/look-at/image-converter.test.ts diff --git a/src/tools/look-at/image-converter.test.ts b/src/tools/look-at/image-converter.test.ts new file mode 100644 index 000000000..37c9cdf2a --- /dev/null +++ b/src/tools/look-at/image-converter.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" +import { existsSync, mkdtempSync, writeFileSync, unlinkSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const originalChildProcess = await import("node:child_process") + +const execFileSyncMock = mock((_command: string, _args: string[]) => "") +const execSyncMock = mock(() => { + throw new Error("execSync should not be called") +}) + +mock.module("node:child_process", () => ({ + ...originalChildProcess, + execFileSync: execFileSyncMock, + execSync: execSyncMock, +})) + +const { convertImageToJpeg } = await import("./image-converter") + +describe("image-converter command execution safety", () => { + beforeEach(() => { + execFileSyncMock.mockReset() + execSyncMock.mockReset() + }) + + test("uses execFileSync with argument arrays for conversion commands", () => { + const testDir = mkdtempSync(join(tmpdir(), "img-converter-test-")) + const inputPath = join(testDir, "evil$(touch_pwn).heic") + writeFileSync(inputPath, "fake-heic-data") + + execFileSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === "sips") { + const outIndex = args.indexOf("--out") + const outputPath = outIndex >= 0 ? args[outIndex + 1] : undefined + if (outputPath) writeFileSync(outputPath, "jpeg") + } else if (command === "convert") { + writeFileSync(args[1], "jpeg") + } + return "" + }) + + const outputPath = convertImageToJpeg(inputPath, "image/heic") + + expect(execSyncMock).not.toHaveBeenCalled() + expect(execFileSyncMock).toHaveBeenCalled() + + const [firstCommand, firstArgs] = execFileSyncMock.mock.calls[0] as [string, string[]] + expect(typeof firstCommand).toBe("string") + expect(Array.isArray(firstArgs)).toBe(true) + expect(firstArgs).toContain(inputPath) + expect(firstArgs.join(" ")).not.toContain(`\"${inputPath}\"`) + + expect(existsSync(outputPath)).toBe(true) + + if (existsSync(outputPath)) unlinkSync(outputPath) + if (existsSync(inputPath)) unlinkSync(inputPath) + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/tools/look-at/image-converter.ts b/src/tools/look-at/image-converter.ts index 6718bd0fa..e95237ba3 100644 --- a/src/tools/look-at/image-converter.ts +++ b/src/tools/look-at/image-converter.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process" +import { execFileSync } from "node:child_process" import { existsSync, mkdtempSync, unlinkSync, writeFileSync, readFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -57,7 +57,7 @@ export function convertImageToJpeg(inputPath: string, mimeType: string): string try { if (process.platform === "darwin") { try { - execSync(`sips -s format jpeg "${inputPath}" --out "${outputPath}"`, { + execFileSync("sips", ["-s", "format", "jpeg", inputPath, "--out", outputPath], { stdio: "pipe", encoding: "utf-8", }) @@ -72,7 +72,7 @@ export function convertImageToJpeg(inputPath: string, mimeType: string): string } try { - execSync(`convert "${inputPath}" "${outputPath}"`, { + execFileSync("convert", [inputPath, outputPath], { stdio: "pipe", encoding: "utf-8", }) From b94b193c21599d52648c54803a81dc0e85a3b960 Mon Sep 17 00:00:00 2001 From: IYODA Atsushi Date: Fri, 20 Feb 2026 03:37:01 +0900 Subject: [PATCH 06/62] fix(doctor): point fix messages to actual cache directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor's fix messages for outdated/mismatched plugin versions were directing users to ~/.config/opencode with `bun update`, but OpenCode loads plugins from its cache directory (~/.cache/opencode on Linux, ~/Library/Caches/opencode on macOS). Additionally, pinned versions in the cache package.json make `bun update` a no-op β€” `bun add ...@latest` is required. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/cli/doctor/checks/system.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/doctor/checks/system.ts b/src/cli/doctor/checks/system.ts index 05d32d681..01fa162d1 100644 --- a/src/cli/doctor/checks/system.ts +++ b/src/cli/doctor/checks/system.ts @@ -93,7 +93,7 @@ export async function checkSystem(): Promise { issues.push({ title: "Loaded plugin version mismatch", description: `Cache expects ${loadedInfo.expectedVersion} but loaded ${loadedInfo.loadedVersion}.`, - fix: "Reinstall plugin dependencies in OpenCode cache", + fix: `Reinstall: cd ${loadedInfo.cacheDir} && bun install`, severity: "warning", affects: ["plugin loading"], }) @@ -107,7 +107,7 @@ export async function checkSystem(): Promise { issues.push({ title: "Loaded plugin is outdated", description: `Loaded ${systemInfo.loadedVersion}, latest ${latestVersion}.`, - fix: "Update: cd ~/.config/opencode && bun update oh-my-opencode", + fix: `Update: cd ${loadedInfo.cacheDir} && bun add oh-my-opencode@latest`, severity: "warning", affects: ["plugin features"], }) From 0dee4377b8481a9037d182588e72f9a86eb6f297 Mon Sep 17 00:00:00 2001 From: Gershom Rogers Date: Sat, 21 Feb 2026 10:05:50 -0500 Subject: [PATCH 07/62] feat(dispatch): wire marketplace plugin commands into slash command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect the existing plugin loader infrastructure to both slash command dispatch paths (executor and slashcommand tool), enabling namespaced commands like /daplug:run-prompt to resolve and execute. - Add plugin discovery to executor.ts discoverAllCommands() - Add plugin discovery to command-discovery.ts discoverCommandsSync() - Add "plugin" to CommandScope type - Remove blanket colon-rejection error (replaced with standard not-found) - Update slash command regex to accept namespaced commands - Thread claude_code.plugins config toggle through dispatch chain - Add unit tests for plugin command discovery and dispatch Closes #2019 πŸ€– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Co-Authored-By: Codex --- src/create-hooks.ts | 1 + src/hooks/auto-slash-command/constants.ts | 2 +- src/hooks/auto-slash-command/detector.test.ts | 13 ++ src/hooks/auto-slash-command/executor.test.ts | 168 ++++++++++++++++++ src/hooks/auto-slash-command/executor.ts | 43 ++++- src/hooks/auto-slash-command/hook.ts | 4 + src/plugin/hooks/create-skill-hooks.ts | 18 +- src/plugin/tool-registry.ts | 5 +- src/tools/skill/tools.test.ts | 2 +- src/tools/skill/tools.ts | 8 +- src/tools/skill/types.ts | 4 + .../slashcommand/command-discovery.test.ts | 160 +++++++++++++++++ src/tools/slashcommand/command-discovery.ts | 45 ++++- src/tools/slashcommand/types.ts | 2 +- 14 files changed, 461 insertions(+), 14 deletions(-) create mode 100644 src/hooks/auto-slash-command/executor.test.ts create mode 100644 src/tools/slashcommand/command-discovery.test.ts diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 9972551e8..121b0f53e 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -51,6 +51,7 @@ export function createHooks(args: { const skill = createSkillHooks({ ctx, + pluginConfig, isHookEnabled, safeHookEnabled, mergedSkills, diff --git a/src/hooks/auto-slash-command/constants.ts b/src/hooks/auto-slash-command/constants.ts index de2a49a7a..a8bdac19e 100644 --- a/src/hooks/auto-slash-command/constants.ts +++ b/src/hooks/auto-slash-command/constants.ts @@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const export const AUTO_SLASH_COMMAND_TAG_OPEN = "" export const AUTO_SLASH_COMMAND_TAG_CLOSE = "" -export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/ +export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w:@/-]*)\s*(.*)/ export const EXCLUDED_COMMANDS = new Set([ "ralph-loop", diff --git a/src/hooks/auto-slash-command/detector.test.ts b/src/hooks/auto-slash-command/detector.test.ts index ce87c2d9c..36eb8bc6d 100644 --- a/src/hooks/auto-slash-command/detector.test.ts +++ b/src/hooks/auto-slash-command/detector.test.ts @@ -102,6 +102,19 @@ After` expect(result?.args).toBe("project") }) + it("should parse namespaced marketplace commands", () => { + // given a namespaced command + const text = "/daplug:run-prompt build bridge" + + // when parsing + const result = parseSlashCommand(text) + + // then should keep full namespaced command + expect(result).not.toBeNull() + expect(result?.command).toBe("daplug:run-prompt") + expect(result?.args).toBe("build bridge") + }) + it("should return null for non-slash text", () => { // given text without slash const text = "regular text" diff --git a/src/hooks/auto-slash-command/executor.test.ts b/src/hooks/auto-slash-command/executor.test.ts new file mode 100644 index 000000000..979215fae --- /dev/null +++ b/src/hooks/auto-slash-command/executor.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { executeSlashCommand } from "./executor" + +const ENV_KEYS = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_PLUGINS_HOME", + "CLAUDE_SETTINGS_PATH", + "OPENCODE_CONFIG_DIR", +] as const + +type EnvKey = (typeof ENV_KEYS)[number] +type EnvSnapshot = Record + +function writePluginFixture(baseDir: string): void { + const claudeConfigDir = join(baseDir, "claude-config") + const pluginsHome = join(claudeConfigDir, "plugins") + const settingsPath = join(claudeConfigDir, "settings.json") + const opencodeConfigDir = join(baseDir, "opencode-config") + const pluginInstallPath = join(baseDir, "installed-plugins", "daplug") + const pluginKey = "daplug@1.0.0" + + mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "commands"), { recursive: true }) + + writeFileSync( + join(pluginInstallPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2), + ) + writeFileSync( + join(pluginInstallPath, "commands", "run-prompt.md"), + `--- +description: Run prompt from daplug +--- +Execute daplug prompt flow. +`, + ) + + mkdirSync(pluginsHome, { recursive: true }) + writeFileSync( + join(pluginsHome, "installed_plugins.json"), + JSON.stringify( + { + version: 2, + plugins: { + [pluginKey]: [ + { + scope: "user", + installPath: pluginInstallPath, + version: "1.0.0", + installedAt: "2026-01-01T00:00:00.000Z", + lastUpdated: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }, + null, + 2, + ), + ) + + mkdirSync(claudeConfigDir, { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + enabledPlugins: { + [pluginKey]: true, + }, + }, + null, + 2, + ), + ) + mkdirSync(opencodeConfigDir, { recursive: true }) + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + process.env.CLAUDE_PLUGINS_HOME = pluginsHome + process.env.CLAUDE_SETTINGS_PATH = settingsPath + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir +} + +describe("auto-slash command executor plugin dispatch", () => { + let tempDir = "" + let envSnapshot: EnvSnapshot + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-")) + envSnapshot = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME, + CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + } + writePluginFixture(tempDir) + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + const previousValue = envSnapshot[key] + if (previousValue === undefined) { + delete process.env[key] + } else { + process.env[key] = previousValue + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("resolves marketplace plugin commands when plugin loading is enabled", async () => { + const result = await executeSlashCommand( + { + command: "daplug:run-prompt", + args: "ship it", + raw: "/daplug:run-prompt ship it", + }, + { + skills: [], + pluginsEnabled: true, + }, + ) + + expect(result.success).toBe(true) + expect(result.replacementText).toContain("# /daplug:run-prompt Command") + expect(result.replacementText).toContain("**Scope**: plugin") + }) + + it("excludes marketplace commands when plugins are disabled via config toggle", async () => { + const result = await executeSlashCommand( + { + command: "daplug:run-prompt", + args: "", + raw: "/daplug:run-prompt", + }, + { + skills: [], + pluginsEnabled: false, + }, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe( + 'Command "/daplug:run-prompt" not found. Use the skill tool to list available skills and commands.', + ) + }) + + it("returns standard not-found for unknown namespaced commands", async () => { + const result = await executeSlashCommand( + { + command: "daplug:missing", + args: "", + raw: "/daplug:missing", + }, + { + skills: [], + pluginsEnabled: true, + }, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe( + 'Command "/daplug:missing" not found. Use the skill tool to list available skills and commands.', + ) + expect(result.error).not.toContain("Marketplace plugin commands") + }) +}) diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index ffa96be8b..f7c906e20 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -12,10 +12,15 @@ import { loadBuiltinCommands } from "../../features/builtin-commands" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader" +import { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, +} from "../../features/claude-code-plugin-loader" import type { ParsedSlashCommand } from "./types" interface CommandScope { - type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" + type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin" } interface CommandMetadata { @@ -99,6 +104,36 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo { export interface ExecutorOptions { skills?: LoadedSkill[] + pluginsEnabled?: boolean + enabledPluginsOverride?: Record +} + +function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] { + if (options?.pluginsEnabled === false) { + return [] + } + + const { plugins } = discoverInstalledPlugins({ + enabledPluginsOverride: options?.enabledPluginsOverride, + }) + + const pluginDefinitions = { + ...loadPluginCommands(plugins), + ...loadPluginSkillsAsCommands(plugins), + } + + return Object.entries(pluginDefinitions).map(([name, definition]) => ({ + name, + metadata: { + name, + description: definition.description || "", + model: definition.model, + agent: definition.agent, + subtask: definition.subtask, + }, + content: definition.template, + scope: "plugin", + })) } async function discoverAllCommands(options?: ExecutorOptions): Promise { @@ -128,6 +163,7 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise() export interface AutoSlashCommandHookOptions { skills?: LoadedSkill[] + pluginsEnabled?: boolean + enabledPluginsOverride?: Record } export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) { const executorOptions: ExecutorOptions = { skills: options?.skills, + pluginsEnabled: options?.pluginsEnabled, + enabledPluginsOverride: options?.enabledPluginsOverride, } return { diff --git a/src/plugin/hooks/create-skill-hooks.ts b/src/plugin/hooks/create-skill-hooks.ts index 043a0bbbb..b0514d583 100644 --- a/src/plugin/hooks/create-skill-hooks.ts +++ b/src/plugin/hooks/create-skill-hooks.ts @@ -1,5 +1,5 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" -import type { HookName } from "../../config" +import type { HookName, OhMyOpenCodeConfig } from "../../config" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import type { PluginContext } from "../types" @@ -13,12 +13,20 @@ export type SkillHooks = { export function createSkillHooks(args: { ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig isHookEnabled: (hookName: HookName) => boolean safeHookEnabled: boolean mergedSkills: LoadedSkill[] availableSkills: AvailableSkill[] }): SkillHooks { - const { ctx, isHookEnabled, safeHookEnabled, mergedSkills, availableSkills } = args + const { + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + mergedSkills, + availableSkills, + } = args const safeHook = (hookName: HookName, factory: () => T): T | null => safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) @@ -30,7 +38,11 @@ export function createSkillHooks(args: { const autoSlashCommand = isHookEnabled("auto-slash-command") ? safeHook("auto-slash-command", () => - createAutoSlashCommandHook({ skills: mergedSkills })) + createAutoSlashCommandHook({ + skills: mergedSkills, + pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, + enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + })) : null return { categorySkillReminder, autoSlashCommand } diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 21d7901f4..80ee0c576 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -94,7 +94,10 @@ export function createToolRegistry(args: { getSessionID: getSessionIDForMcp, }) - const commands = discoverCommandsSync(ctx.directory) + const commands = discoverCommandsSync(ctx.directory, { + pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, + enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + }) const skillTool = createSkillTool({ commands, skills: skillContext.mergedSkills, diff --git a/src/tools/skill/tools.test.ts b/src/tools/skill/tools.test.ts index d4f2d01f6..e64a20fb4 100644 --- a/src/tools/skill/tools.test.ts +++ b/src/tools/skill/tools.test.ts @@ -464,7 +464,7 @@ describe("skill tool - ordering and priority", () => { const tool = createSkillTool({ skills, commands }) //#then: should include priority info - expect(tool.description).toContain("Priority: project > user > opencode > builtin") + expect(tool.description).toContain("Priority: project > user > opencode > builtin/plugin") expect(tool.description).toContain("Skills listed before commands") }) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 044776909..4bdfb42a0 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -16,6 +16,7 @@ const scopePriority: Record = { user: 3, opencode: 2, "opencode-project": 2, + plugin: 1, config: 1, builtin: 1, } @@ -89,7 +90,7 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]) } if (allItems.length > 0) { - lines.push(`\n\nPriority: project > user > opencode > builtin | Skills listed before commands\nInvoke via: skill(name="item-name") β€” omit leading slash for commands.\n${allItems.join("\n")}\n`) + lines.push(`\n\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") β€” omit leading slash for commands.\n${allItems.join("\n")}\n`) } return TOOL_DESCRIPTION_PREFIX + lines.join("") @@ -195,7 +196,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition const getCommands = (): CommandInfo[] => { if (cachedCommands) return cachedCommands - cachedCommands = discoverCommandsSync() + cachedCommands = discoverCommandsSync(undefined, { + pluginsEnabled: options.pluginsEnabled, + enabledPluginsOverride: options.enabledPluginsOverride, + }) return cachedCommands } diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index 4fd48d6c7..579eb69cc 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -33,4 +33,8 @@ export interface SkillLoadOptions { /** Git master configuration for watermark/co-author settings */ gitMasterConfig?: GitMasterConfig disabledSkills?: Set + /** Include Claude marketplace plugin commands in discovery (default: true) */ + pluginsEnabled?: boolean + /** Override plugin enablement from Claude settings by plugin key */ + enabledPluginsOverride?: Record } diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts new file mode 100644 index 000000000..05e49ab3d --- /dev/null +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { discoverCommandsSync } from "./command-discovery" + +const ENV_KEYS = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_PLUGINS_HOME", + "CLAUDE_SETTINGS_PATH", + "OPENCODE_CONFIG_DIR", +] as const + +type EnvKey = (typeof ENV_KEYS)[number] +type EnvSnapshot = Record + +function writePluginFixture(baseDir: string): { projectDir: string } { + const projectDir = join(baseDir, "project") + const claudeConfigDir = join(baseDir, "claude-config") + const pluginsHome = join(claudeConfigDir, "plugins") + const settingsPath = join(claudeConfigDir, "settings.json") + const opencodeConfigDir = join(baseDir, "opencode-config") + const pluginInstallPath = join(baseDir, "installed-plugins", "daplug") + const pluginKey = "daplug@1.0.0" + + mkdirSync(projectDir, { recursive: true }) + mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "commands"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "skills", "plugin-plan"), { recursive: true }) + + writeFileSync( + join(pluginInstallPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2), + ) + writeFileSync( + join(pluginInstallPath, "commands", "run-prompt.md"), + `--- +description: Run prompt from daplug +--- +Execute daplug prompt flow. +`, + ) + writeFileSync( + join(pluginInstallPath, "skills", "plugin-plan", "SKILL.md"), + `--- +name: plugin-plan +description: Plan work from daplug skill +--- +Build a plan from plugin skill context. +`, + ) + + mkdirSync(pluginsHome, { recursive: true }) + writeFileSync( + join(pluginsHome, "installed_plugins.json"), + JSON.stringify( + { + version: 2, + plugins: { + [pluginKey]: [ + { + scope: "user", + installPath: pluginInstallPath, + version: "1.0.0", + installedAt: "2026-01-01T00:00:00.000Z", + lastUpdated: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }, + null, + 2, + ), + ) + + mkdirSync(claudeConfigDir, { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + enabledPlugins: { + [pluginKey]: true, + }, + }, + null, + 2, + ), + ) + mkdirSync(opencodeConfigDir, { recursive: true }) + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + process.env.CLAUDE_PLUGINS_HOME = pluginsHome + process.env.CLAUDE_SETTINGS_PATH = settingsPath + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir + + return { projectDir } +} + +describe("slashcommand command discovery plugin integration", () => { + let tempDir = "" + let projectDir = "" + let envSnapshot: EnvSnapshot + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-command-discovery-test-")) + envSnapshot = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME, + CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + } + const setup = writePluginFixture(tempDir) + projectDir = setup.projectDir + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + const previousValue = envSnapshot[key] + if (previousValue === undefined) { + delete process.env[key] + } else { + process.env[key] = previousValue + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("discovers marketplace plugin commands and skills as command items", () => { + const commands = discoverCommandsSync(projectDir, { pluginsEnabled: true }) + const names = commands.map(command => command.name) + + expect(names).toContain("daplug:run-prompt") + expect(names).toContain("daplug:plugin-plan") + + const pluginCommand = commands.find(command => command.name === "daplug:run-prompt") + const pluginSkill = commands.find(command => command.name === "daplug:plugin-plan") + + expect(pluginCommand?.scope).toBe("plugin") + expect(pluginSkill?.scope).toBe("plugin") + }) + + it("omits marketplace plugin commands when plugins are disabled", () => { + const commands = discoverCommandsSync(projectDir, { pluginsEnabled: false }) + const names = commands.map(command => command.name) + + expect(names).not.toContain("daplug:run-prompt") + expect(names).not.toContain("daplug:plugin-plan") + }) + + it("honors plugins_override by disabling overridden plugin keys", () => { + const commands = discoverCommandsSync(projectDir, { + pluginsEnabled: true, + enabledPluginsOverride: { "daplug@1.0.0": false }, + }) + const names = commands.map(command => command.name) + + expect(names).not.toContain("daplug:run-prompt") + expect(names).not.toContain("daplug:plugin-plan") + }) +}) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index d06990036..57182f020 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -5,8 +5,18 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import { loadBuiltinCommands } from "../../features/builtin-commands" +import { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, +} from "../../features/claude-code-plugin-loader" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" +export interface CommandDiscoveryOptions { + pluginsEnabled?: boolean + enabledPluginsOverride?: Record +} + function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { if (!existsSync(commandsDir)) return [] @@ -48,7 +58,38 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm return commands } -export function discoverCommandsSync(directory?: string): CommandInfo[] { +function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[] { + if (options?.pluginsEnabled === false) { + return [] + } + + const { plugins } = discoverInstalledPlugins({ + enabledPluginsOverride: options?.enabledPluginsOverride, + }) + + const pluginDefinitions = { + ...loadPluginCommands(plugins), + ...loadPluginSkillsAsCommands(plugins), + } + + return Object.entries(pluginDefinitions).map(([name, definition]) => ({ + name, + metadata: { + name, + description: definition.description || "", + model: definition.model, + agent: definition.agent, + subtask: definition.subtask, + }, + content: definition.template, + scope: "plugin", + })) +} + +export function discoverCommandsSync( + directory?: string, + options?: CommandDiscoveryOptions, +): CommandInfo[] { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands") @@ -59,6 +100,7 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] { const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") + const pluginCommands = discoverPluginCommands(options) const builtinCommandsMap = loadBuiltinCommands() const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map((command) => ({ @@ -81,5 +123,6 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] { ...opencodeProjectCommands, ...opencodeGlobalCommands, ...builtinCommands, + ...pluginCommands, ] } diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 090e12178..af3935ef3 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,6 +1,6 @@ import type { LazyContentLoader } from "../../features/opencode-skill-loader" -export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" +export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" | "plugin" export interface CommandMetadata { name: string From 584a82ea2033301949e002b18cfc59494488f210 Mon Sep 17 00:00:00 2001 From: Zhendong Li <54206290+DMax1314@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:59:43 -0500 Subject: [PATCH 08/62] Update Kimi Code Subscription link in README The link does not work anymore. You can use your referral link if you'd like. This one I'm sharing is just a direct link. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ce701e7a..00bde1db0 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Everything below, every feature, every optimization, you don't need to know it. Even only with following subscriptions, ultrawork will work well (this project is not affiliated, this is just personal recommendation): - [ChatGPT Subscription ($20)](https://chatgpt.com/) -- [Kimi Code Subscription ($0.99) (*only this month)](https://www.kimi.com/membership/pricing?track_id=5cdeca93-66f0-4d35-aabb-b6df8fcea328) +- [Kimi Code Subscription ($0.99) (*only this month)](https://www.kimi.com/kimiplus/sale) - [GLM Coding Plan ($10)](https://z.ai/subscribe) - If you are eligible for pay-per-token, using kimi and gemini models won't cost you that much. From 13716f78aa55756f05d5ae2d6d134ef474391605 Mon Sep 17 00:00:00 2001 From: Firstbober Date: Mon, 23 Feb 2026 17:42:53 +0100 Subject: [PATCH 09/62] fix: model format normalization and explicit config cache bypass - Add normalizeModelFormat() utility for string/object model handling - Update subagent-resolver to handle both model formats - Add explicitUserConfig flag to ModelResolutionResult - Set explicitUserConfig: true when user model is found in pipeline This fixes the issue where plugin-provided models fail cache validation and fall through to random fallback models. --- src/shared/model-format-normalizer.test.ts | 46 ++++++++++++++++++++ src/shared/model-format-normalizer.ts | 20 +++++++++ src/shared/model-resolution-pipeline.ts | 3 +- src/tools/delegate-task/subagent-resolver.ts | 13 +++--- 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 src/shared/model-format-normalizer.test.ts create mode 100644 src/shared/model-format-normalizer.ts diff --git a/src/shared/model-format-normalizer.test.ts b/src/shared/model-format-normalizer.test.ts new file mode 100644 index 000000000..d28ab975b --- /dev/null +++ b/src/shared/model-format-normalizer.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "bun:test" +import { normalizeModelFormat } from "./model-format-normalizer" + +describe("normalizeModelFormat", () => { + describe("string format input", () => { + it("splits provider/model format correctly", () => { + const result = normalizeModelFormat("opencode/glm-5-free") + expect(result).toEqual({ providerID: "opencode", modelID: "glm-5-free" }) + }) + + it("handles provider with multiple slashes", () => { + const result = normalizeModelFormat("anthropic/claude-opus-4-6/max") + expect(result).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6/max" }) + }) + + it("returns undefined for malformed string without separator", () => { + const result = normalizeModelFormat("invalid") + expect(result).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + const result = normalizeModelFormat("") + expect(result).toBeUndefined() + }) + }) + + describe("object format input", () => { + it("passthroughs object format unchanged", () => { + const input = { providerID: "opencode", modelID: "glm-5-free" } + const result = normalizeModelFormat(input) + expect(result).toEqual(input) + }) + }) + + describe("edge cases", () => { + it("returns undefined for null", () => { + const result = normalizeModelFormat(null) + expect(result).toBeUndefined() + }) + + it("returns undefined for undefined", () => { + const result = normalizeModelFormat(undefined) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/src/shared/model-format-normalizer.ts b/src/shared/model-format-normalizer.ts new file mode 100644 index 000000000..98d255f78 --- /dev/null +++ b/src/shared/model-format-normalizer.ts @@ -0,0 +1,20 @@ +export function normalizeModelFormat( + model: string | { providerID: string; modelID: string } +): { providerID: string; modelID: string } | undefined { + if (!model) { + return undefined + } + + if (typeof model === "object" && "providerID" in model && "modelID" in model) { + return { providerID: model.providerID, modelID: model.modelID } + } + + if (typeof model === "string") { + const parts = model.split("/") + if (parts.length >= 2) { + return { providerID: parts[0], modelID: parts.slice(1).join("/") } + } + } + + return undefined +} diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index 0d90b4f16..06a2f8cfe 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -33,6 +33,7 @@ export type ModelResolutionResult = { variant?: string attempted?: string[] reason?: string + explicitUserConfig?: boolean } function normalizeModel(model?: string): string | undefined { @@ -58,7 +59,7 @@ export function resolveModelPipeline( const normalizedUserModel = normalizeModel(intent?.userModel) if (normalizedUserModel) { log("Model resolved via config override", { model: normalizedUserModel }) - return { model: normalizedUserModel, provenance: "override" } + return { model: normalizedUserModel, provenance: "override", explicitUserConfig: true } } const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 043243db2..1d0e65db6 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -2,7 +2,7 @@ import type { DelegateTaskArgs } from "./types" import type { ExecutorContext } from "./executor-types" import { isPlanFamily } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" -import { parseModelString } from "./model-string-parser" +import { normalizeModelFormat } from "../../shared/model-format-normalizer" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names" import { normalizeSDKResponse } from "../../shared" @@ -99,8 +99,9 @@ Create the work plan directly - that's your job as the planning agent.`, if (agentOverride?.model || agentRequirement || matchedAgent.model) { const availableModels = await getAvailableModelsForDelegateTask(client) - const matchedAgentModelStr = matchedAgent.model - ? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}` + const normalizedMatchedModel = normalizeModelFormat(matchedAgent.model as Parameters[0]) + const matchedAgentModelStr = normalizedMatchedModel + ? `${normalizedMatchedModel.providerID}/${normalizedMatchedModel.modelID}` : undefined const resolution = resolveModelForDelegateTask({ @@ -112,10 +113,10 @@ Create the work plan directly - that's your job as the planning agent.`, }) if (resolution) { - const parsed = parseModelString(resolution.model) - if (parsed) { + const normalized = normalizeModelFormat(resolution.model) + if (normalized) { const variantToUse = agentOverride?.variant ?? resolution.variant - categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed + categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized } } } From ad79246376ef01554cb2319bfa230b88a25fc59c Mon Sep 17 00:00:00 2001 From: MoerAI Date: Fri, 20 Feb 2026 10:47:10 +0900 Subject: [PATCH 10/62] fix(config): respect user's external_directory permission setting applyToolConfig() forcibly overrode the user's external_directory permission to 'allow' by placing OMO defaults after the user config spread. Reorder so defaults come first and user config spreads on top, allowing users to set 'ask' or 'deny'. The task permission remains forced to 'deny' after the spread for security. Closes #1973 --- src/plugin-handlers/tool-config-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index e488d2da9..381dbb55c 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -99,9 +99,9 @@ export function applyToolConfig(params: { } params.config.permission = { - ...(params.config.permission as Record), webfetch: "allow", external_directory: "allow", + ...(params.config.permission as Record), task: "deny", }; } From 8d66ab742bab6ed4bac977eaa0c99c4544da4eab Mon Sep 17 00:00:00 2001 From: MoerAI Date: Mon, 23 Feb 2026 10:21:45 +0900 Subject: [PATCH 11/62] fix(test): update EventState inline literal to use createEventState() spread EventState interface gained new required fields; the inline literal in the session.status test was missing them, causing type errors and runtime failures. --- src/cli/run/events.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/cli/run/events.test.ts b/src/cli/run/events.test.ts index 2afc216f8..502db8623 100644 --- a/src/cli/run/events.test.ts +++ b/src/cli/run/events.test.ts @@ -318,14 +318,8 @@ describe("event handling", () => { // given const ctx = createMockContext("my-session") const state: EventState = { + ...createEventState(), mainSessionIdle: true, - mainSessionError: false, - lastError: null, - lastOutput: "", - lastPartText: "", - currentTool: null, - hasReceivedMeaningfulWork: false, - messageCount: 0, } const payload: EventPayload = { From ae12f2e9d2787ac3e759f1901a36b0aab5570039 Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 19:57:35 +0100 Subject: [PATCH 12/62] feat(config): add custom_agents overrides and strict agent validation --- assets/oh-my-opencode.schema.json | 204 ++++++++++++++++++ src/config/index.ts | 14 ++ src/config/schema-document.test.ts | 32 +++ src/config/schema.test.ts | 73 +++++++ src/config/schema/agent-overrides.ts | 36 +++- src/config/schema/oh-my-opencode-config.ts | 3 +- src/plugin-config.test.ts | 138 +++++++++++- src/plugin-config.ts | 151 ++++++++++++- src/plugin-handlers/agent-config-handler.ts | 83 +++++-- src/plugin-handlers/config-handler.test.ts | 191 ++++++++++++++++ src/plugin-handlers/custom-agent-utils.ts | 136 ++++++++++++ .../prometheus-agent-config-builder.ts | 11 +- .../delegate-task/subagent-resolver.test.ts | 52 +++++ src/tools/delegate-task/subagent-resolver.ts | 14 +- src/tools/delegate-task/tools.ts | 9 +- 15 files changed, 1120 insertions(+), 27 deletions(-) create mode 100644 src/config/schema-document.test.ts create mode 100644 src/plugin-handlers/custom-agent-utils.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index d87cf68cd..3b489819f 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3157,6 +3157,210 @@ }, "additionalProperties": false }, + "custom_agents": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^(?!(?:build|plan|sisyphus|hephaestus|sisyphus-junior|OpenCode-Builder|prometheus|metis|momus|oracle|librarian|explore|multimodal-looker|atlas)$).+" + }, + "additionalProperties": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": [ + "model" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "additionalProperties": false + } + }, "categories": { "type": "object", "propertyNames": { diff --git a/src/config/index.ts b/src/config/index.ts index 2f7f98578..ae2ef967f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,11 +1,25 @@ export { OhMyOpenCodeConfigSchema, + AgentOverrideConfigSchema, + AgentOverridesSchema, + CustomAgentOverridesSchema, + McpNameSchema, + AgentNameSchema, + OverridableAgentNameSchema, + HookNameSchema, + BuiltinCommandNameSchema, + SisyphusAgentConfigSchema, + ExperimentalConfigSchema, + RalphLoopConfigSchema, + TmuxConfigSchema, + TmuxLayoutSchema, } from "./schema" export type { OhMyOpenCodeConfig, AgentOverrideConfig, AgentOverrides, + CustomAgentOverrides, McpName, AgentName, HookName, diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts new file mode 100644 index 000000000..12cc09b87 --- /dev/null +++ b/src/config/schema-document.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { createOhMyOpenCodeJsonSchema } from "../../script/build-schema-document" + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +describe("schema document generation", () => { + test("custom_agents schema allows arbitrary custom agent keys with override shape", () => { + // given + const schema = createOhMyOpenCodeJsonSchema() + + // when + const rootProperties = asRecord(schema.properties) + const agentsSchema = asRecord(rootProperties?.agents) + const customAgentsSchema = asRecord(rootProperties?.custom_agents) + const customPropertyNames = asRecord(customAgentsSchema?.propertyNames) + const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) + const customAgentProperties = asRecord(customAdditionalProperties?.properties) + + // then + expect(agentsSchema).toBeDefined() + expect(agentsSchema?.additionalProperties).toBeFalse() + expect(customAgentsSchema).toBeDefined() + expect(customPropertyNames?.pattern).toBeDefined() + expect(customAdditionalProperties).toBeDefined() + expect(customAgentProperties?.model).toEqual({ type: "string" }) + expect(customAgentProperties?.temperature).toEqual( + expect.objectContaining({ type: "number" }), + ) + }) +}) diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 8a83fcd7d..477eaa51b 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -530,6 +530,79 @@ describe("Sisyphus-Junior agent override", () => { expect(result.data.agents?.momus?.category).toBe("quick") } }) + + test("schema accepts custom_agents override keys", () => { + // given + const config = { + custom_agents: { + translator: { + model: "google/gemini-3-flash-preview", + temperature: 0, + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview") + expect(result.data.custom_agents?.translator?.temperature).toBe(0) + } + }) + + test("schema rejects unknown keys under agents", () => { + // given + const config = { + agents: { + sisyphuss: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("schema rejects built-in agent names under custom_agents", () => { + // given + const config = { + custom_agents: { + sisyphus: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("schema rejects built-in agent names under custom_agents case-insensitively", () => { + // given + const config = { + custom_agents: { + Sisyphus: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) }) describe("BrowserAutomationProviderSchema", () => { diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index 7b3aa8d7a..1103bf15a 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -1,5 +1,6 @@ import { z } from "zod" import { FallbackModelsSchema } from "./fallback-models" +import { OverridableAgentNameSchema } from "./agent-names" import { AgentPermissionSchema } from "./internal/permission" export const AgentOverrideConfigSchema = z.object({ @@ -55,7 +56,7 @@ export const AgentOverrideConfigSchema = z.object({ .optional(), }) -export const AgentOverridesSchema = z.object({ +const BuiltinAgentOverridesSchema = z.object({ build: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(), sisyphus: AgentOverrideConfigSchema.optional(), @@ -70,7 +71,38 @@ export const AgentOverridesSchema = z.object({ explore: AgentOverrideConfigSchema.optional(), "multimodal-looker": AgentOverrideConfigSchema.optional(), atlas: AgentOverrideConfigSchema.optional(), -}) +}).strict() + +export const AgentOverridesSchema = BuiltinAgentOverridesSchema + +const RESERVED_CUSTOM_AGENT_NAMES = OverridableAgentNameSchema.options +const RESERVED_CUSTOM_AGENT_NAME_SET = new Set( + RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.toLowerCase()), +) +const RESERVED_CUSTOM_AGENT_NAME_PATTERN = new RegExp( + `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$).+`, +) + +export const CustomAgentOverridesSchema = z + .record( + z.string().regex( + RESERVED_CUSTOM_AGENT_NAME_PATTERN, + "custom_agents key cannot reuse built-in agent override name", + ), + AgentOverrideConfigSchema, + ) + .superRefine((value, ctx) => { + for (const key of Object.keys(value)) { + if (RESERVED_CUSTOM_AGENT_NAME_SET.has(key.toLowerCase())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: "custom_agents key cannot reuse built-in agent override name", + }) + } + } + }) export type AgentOverrideConfig = z.infer export type AgentOverrides = z.infer +export type CustomAgentOverrides = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index b36e8688f..ceb82d451 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { AnyMcpNameSchema } from "../../mcp/types" import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names" -import { AgentOverridesSchema } from "./agent-overrides" +import { AgentOverridesSchema, CustomAgentOverridesSchema } from "./agent-overrides" import { BabysittingConfigSchema } from "./babysitting" import { BackgroundTaskConfigSchema } from "./background-task" import { BrowserAutomationConfigSchema } from "./browser-automation" @@ -38,6 +38,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ /** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */ model_fallback: z.boolean().optional(), agents: AgentOverridesSchema.optional(), + custom_agents: CustomAgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 9404f7095..549c5f1e8 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { mergeConfigs, parseConfigPartially } from "./plugin-config"; +import { + detectLikelyBuiltinAgentTypos, + detectUnknownBuiltinAgentKeys, + mergeConfigs, + parseConfigPartially, +} from "./plugin-config"; import type { OhMyOpenCodeConfig } from "./config"; describe("mergeConfigs", () => { @@ -115,6 +120,27 @@ describe("mergeConfigs", () => { expect(result.disabled_hooks).toContain("session-recovery"); expect(result.disabled_hooks?.length).toBe(3); }); + + it("should deep merge custom_agents", () => { + const base: OhMyOpenCodeConfig = { + custom_agents: { + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const override: OhMyOpenCodeConfig = { + custom_agents: { + translator: { temperature: 0 }, + "database-architect": { model: "openai/gpt-5.3-codex" }, + }, + } + + const result = mergeConfigs(base, override) + + expect(result.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview") + expect(result.custom_agents?.translator?.temperature).toBe(0) + expect(result.custom_agents?.["database-architect"]?.model).toBe("openai/gpt-5.3-codex") + }) }); }); @@ -165,7 +191,9 @@ describe("parseConfigPartially", () => { expect(result).not.toBeNull(); expect(result!.disabled_hooks).toEqual(["comment-checker"]); - expect(result!.agents).toBeUndefined(); + expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2"); + expect(result!.agents?.momus?.model).toBe("openai/gpt-5.2"); + expect((result!.agents as Record)?.prometheus).toBeUndefined(); }); it("should preserve valid agents when a non-agent section is invalid", () => { @@ -182,6 +210,36 @@ describe("parseConfigPartially", () => { expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2"); expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]); }); + + it("should preserve valid built-in agent entries when agents contains unknown keys", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.3-codex" }, + sisyphuss: { model: "openai/gpt-5.3-codex" }, + }, + }; + + const result = parseConfigPartially(rawConfig); + + expect(result).not.toBeNull(); + expect(result!.agents?.sisyphus?.model).toBe("openai/gpt-5.3-codex"); + expect((result!.agents as Record)?.sisyphuss).toBeUndefined(); + }); + + it("should preserve valid custom_agents entries when custom_agents contains reserved names", () => { + const rawConfig = { + custom_agents: { + translator: { model: "google/gemini-3-flash-preview" }, + sisyphus: { model: "openai/gpt-5.3-codex" }, + }, + }; + + const result = parseConfigPartially(rawConfig); + + expect(result).not.toBeNull(); + expect(result!.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview"); + expect((result!.custom_agents as Record)?.sisyphus).toBeUndefined(); + }); }); describe("completely invalid config", () => { @@ -237,3 +295,79 @@ describe("parseConfigPartially", () => { }); }); }); + +describe("detectLikelyBuiltinAgentTypos", () => { + it("detects near-miss builtin agent keys", () => { + const rawConfig = { + agents: { + sisyphuss: { model: "openai/gpt-5.2" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([ + { + key: "sisyphuss", + suggestion: "sisyphus", + }, + ]) + }) + + it("suggests canonical key casing for OpenCode-Builder typos", () => { + const rawConfig = { + agents: { + "opencode-buildr": { model: "openai/gpt-5.2" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([ + { + key: "opencode-buildr", + suggestion: "OpenCode-Builder", + }, + ]) + }) + + it("does not flag valid custom agent names", () => { + const rawConfig = { + agents: { + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([]) + }) +}) + +describe("detectUnknownBuiltinAgentKeys", () => { + it("returns unknown keys under agents", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig) + + expect(unknownKeys).toEqual(["translator"]) + }) + + it("returns empty array when all keys are built-ins", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.2" }, + prometheus: { model: "openai/gpt-5.2" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig) + + expect(unknownKeys).toEqual([]) + }) +}) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index fa22c5b3c..37b3ff49b 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -1,6 +1,10 @@ import * as fs from "fs"; import * as path from "path"; -import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; +import { + OhMyOpenCodeConfigSchema, + OverridableAgentNameSchema, + type OhMyOpenCodeConfig, +} from "./config"; import { log, deepMerge, @@ -11,6 +15,81 @@ import { migrateConfigFile, } from "./shared"; +const BUILTIN_AGENT_OVERRIDE_KEYS = OverridableAgentNameSchema.options; +const BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER = new Map( + BUILTIN_AGENT_OVERRIDE_KEYS.map((key) => [key.toLowerCase(), key]), +); + +function levenshteinDistance(a: string, b: string): number { + const rows = a.length + 1; + const cols = b.length + 1; + const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)); + + for (let i = 0; i < rows; i += 1) matrix[i][0] = i; + for (let j = 0; j < cols; j += 1) matrix[0][j] = j; + + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + + return matrix[rows - 1][cols - 1]; +} + +type AgentTypoWarning = { + key: string; + suggestion: string; +}; + +export function detectLikelyBuiltinAgentTypos( + rawConfig: Record, +): AgentTypoWarning[] { + const agents = rawConfig.agents; + if (!agents || typeof agents !== "object") return []; + + const warnings: AgentTypoWarning[] = []; + for (const key of Object.keys(agents)) { + const lowerKey = key.toLowerCase(); + if (BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lowerKey)) { + continue; + } + + let bestMatchLower: string | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const builtinKey of BUILTIN_AGENT_OVERRIDE_KEYS) { + const distance = levenshteinDistance(lowerKey, builtinKey.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + bestMatchLower = builtinKey.toLowerCase(); + } + } + + if (bestMatchLower && bestDistance <= 2) { + const suggestion = BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.get(bestMatchLower) ?? bestMatchLower; + warnings.push({ key, suggestion }); + } + } + + return warnings; +} + +export function detectUnknownBuiltinAgentKeys( + rawConfig: Record, +): string[] { + const agents = rawConfig.agents; + if (!agents || typeof agents !== "object") return []; + + return Object.keys(agents).filter( + (key) => !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(key.toLowerCase()), + ); +} + export function parseConfigPartially( rawConfig: Record ): OhMyOpenCodeConfig | null { @@ -22,7 +101,52 @@ export function parseConfigPartially( const partialConfig: Record = {}; const invalidSections: string[] = []; + const parseAgentSectionEntries = (sectionKey: "agents" | "custom_agents"): void => { + const rawSection = rawConfig[sectionKey]; + if (!rawSection || typeof rawSection !== "object") return; + + const parsedSection: Record = {}; + const invalidEntries: string[] = []; + + for (const [entryKey, entryValue] of Object.entries(rawSection)) { + const singleEntryResult = OhMyOpenCodeConfigSchema.safeParse({ + [sectionKey]: { [entryKey]: entryValue }, + }); + + if (singleEntryResult.success) { + const parsed = singleEntryResult.data as Record; + const parsedSectionValue = parsed[sectionKey]; + if (parsedSectionValue && typeof parsedSectionValue === "object") { + const typedSection = parsedSectionValue as Record; + if (typedSection[entryKey] !== undefined) { + parsedSection[entryKey] = typedSection[entryKey]; + } + } + continue; + } + + const entryErrors = singleEntryResult.error.issues + .map((issue) => `${entryKey}: ${issue.message}`) + .join(", "); + if (entryErrors) { + invalidEntries.push(entryErrors); + } + } + + if (Object.keys(parsedSection).length > 0) { + partialConfig[sectionKey] = parsedSection; + } + if (invalidEntries.length > 0) { + invalidSections.push(`${sectionKey}: ${invalidEntries.join(", ")}`); + } + }; + for (const key of Object.keys(rawConfig)) { + if (key === "agents" || key === "custom_agents") { + parseAgentSectionEntries(key); + continue; + } + const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] }); if (sectionResult.success) { const parsed = sectionResult.data as Record; @@ -58,6 +182,29 @@ export function loadConfigFromPath( migrateConfigFile(configPath, rawConfig); + const typoWarnings = detectLikelyBuiltinAgentTypos(rawConfig); + if (typoWarnings.length > 0) { + const warningMsg = typoWarnings + .map((warning) => `agents.${warning.key} (did you mean agents.${warning.suggestion}?)`) + .join(", "); + log(`Potential agent override typos in ${configPath}: ${warningMsg}`); + addConfigLoadError({ + path: configPath, + error: `Potential agent override typos detected: ${warningMsg}`, + }); + } + + const unknownAgentKeys = detectUnknownBuiltinAgentKeys(rawConfig); + if (unknownAgentKeys.length > 0) { + const unknownKeysMsg = unknownAgentKeys.map((key) => `agents.${key}`).join(", "); + const migrationHint = "Move custom entries from agents.* to custom_agents.*"; + log(`Unknown built-in agent override keys in ${configPath}: ${unknownKeysMsg}. ${migrationHint}`); + addConfigLoadError({ + path: configPath, + error: `Unknown built-in agent override keys: ${unknownKeysMsg}. ${migrationHint}`, + }); + } + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig); if (result.success) { @@ -98,6 +245,7 @@ export function mergeConfigs( ...base, ...override, agents: deepMerge(base.agents, override.agents), + custom_agents: deepMerge(base.custom_agents, override.custom_agents), categories: deepMerge(base.categories, override.categories), disabled_agents: [ ...new Set([ @@ -170,6 +318,7 @@ export function loadPluginConfig( log("Final merged config", { agents: config.agents, + custom_agents: config.custom_agents, disabled_agents: config.disabled_agents, disabled_mcps: config.disabled_mcps, disabled_hooks: config.disabled_hooks, diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index c5d59e149..088bb1d06 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { log, migrateAgentConfig } from "../shared"; import { AGENT_NAME_MAP } from "../shared/migration"; import { getAgentDisplayName } from "../shared/agent-display-names"; +import { mergeCategories } from "../shared/merge-categories"; import { discoverConfigSourceSkills, discoverOpencodeGlobalSkills, @@ -17,6 +18,13 @@ import { reorderAgentsByPriority } from "./agent-priority-order"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; +import { + applyCustomAgentOverrides, + collectCustomAgentSummariesFromRecord, + mergeCustomAgentSummaries, + collectKnownCustomAgentNames, + filterSummariesByKnownNames, +} from "./custom-agent-utils"; type AgentConfigRecord = Record | undefined> & { build?: Record; @@ -78,22 +86,6 @@ export async function applyAgentConfig(params: { const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - params.pluginConfig.agents, - params.ctx.directory, - currentModel, - params.pluginConfig.categories, - params.pluginConfig.git_master, - allDiscoveredSkills, - params.ctx.client, - browserProvider, - currentModel, - disabledSkills, - useTaskSystem, - disableOmoEnv, - ); - const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; const userAgents = includeClaudeAgents ? loadUserAgents() : {}; const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {}; @@ -106,6 +98,44 @@ export async function applyAgentConfig(params: { ]), ); + const configAgent = params.config.agent as AgentConfigRecord | undefined; + const mergedCategories = mergeCategories(params.pluginConfig.categories) + const knownCustomAgentNames = collectKnownCustomAgentNames( + userAgents as Record, + projectAgents as Record, + pluginAgents as Record, + configAgent as Record | undefined, + ) + + const customAgentSummaries = mergeCustomAgentSummaries( + collectCustomAgentSummariesFromRecord(userAgents as Record), + collectCustomAgentSummariesFromRecord(projectAgents as Record), + collectCustomAgentSummariesFromRecord(pluginAgents as Record), + collectCustomAgentSummariesFromRecord(configAgent as Record | undefined), + filterSummariesByKnownNames( + collectCustomAgentSummariesFromRecord( + params.pluginConfig.custom_agents as Record | undefined, + ), + knownCustomAgentNames, + ), + ) + + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + currentModel, + params.pluginConfig.categories, + params.pluginConfig.git_master, + allDiscoveredSkills, + customAgentSummaries, + browserProvider, + currentModel, + disabledSkills, + useTaskSystem, + disableOmoEnv, + ); + const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -114,8 +144,6 @@ export async function applyAgentConfig(params: { const shouldDemotePlan = plannerEnabled && replacePlan; const configuredDefaultAgent = getConfiguredDefaultAgent(params.config); - const configAgent = params.config.agent as AgentConfigRecord | undefined; - if (isSisyphusEnabled && builtinAgents.sisyphus) { if (configuredDefaultAgent) { (params.config as { default_agent?: string }).default_agent = @@ -159,6 +187,7 @@ export async function applyAgentConfig(params: { pluginPrometheusOverride: prometheusOverride, userCategories: params.pluginConfig.categories, currentModel, + customAgentSummaries, }); } @@ -211,6 +240,24 @@ export async function applyAgentConfig(params: { }; } + if (params.config.agent) { + const builtinOverrideKeys = new Set([ + ...Object.keys(builtinAgents).map((key) => key.toLowerCase()), + "build", + "plan", + "sisyphus-junior", + "opencode-builder", + ]) + + applyCustomAgentOverrides({ + mergedAgents: params.config.agent as Record, + userOverrides: params.pluginConfig.custom_agents, + builtinOverrideKeys, + mergedCategories, + directory: params.ctx.directory, + }) + } + if (params.config.agent) { params.config.agent = remapAgentKeysToDisplayNames( params.config.agent as Record, diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 875a8cee2..c91752dfc 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -162,6 +162,197 @@ describe("Sisyphus-Junior model inheritance", () => { }) }) +describe("custom agent overrides", () => { + test("passes custom agent summaries into builtin agent prompt builder", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize text", + prompt: "Translate content", + }, + }) + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mock: { calls: unknown[][] } + } + + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const firstCallArgs = createBuiltinAgentsMock.mock.calls[0] + expect(firstCallArgs).toBeDefined() + expect(Array.isArray(firstCallArgs[7])).toBe(true) + expect(firstCallArgs[7]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "translator", + description: "Translate and localize text", + }), + ]), + ) + }) + + test("applies oh-my-opencode agent overrides to custom Claude agents", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "(user) translator", + prompt: "Base translator prompt", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + model: "google/gemini-3-flash-preview", + temperature: 0, + prompt_append: "Always preserve placeholders exactly.", + }, + }, + } + + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentConfig = config.agent as Record + expect(agentConfig.translator).toBeDefined() + expect(agentConfig.translator.model).toBe("google/gemini-3-flash-preview") + expect(agentConfig.translator.temperature).toBe(0) + expect(agentConfig.translator.prompt).toContain("Base translator prompt") + expect(agentConfig.translator.prompt).toContain("Always preserve placeholders exactly.") + }) + + test("prometheus prompt includes custom agent catalog for planning", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("") + expect(agentsConfig[pKey].prompt).toContain("translator") + expect(agentsConfig[pKey].prompt).toContain("Translate and localize locale files") + }) + + test("prometheus prompt excludes unknown custom_agents entries", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + description: "Translate and localize locale files", + }, + ghostwriter: { + description: "This agent does not exist in runtime", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("translator") + expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") + }) +}) + describe("Plan agent demote behavior", () => { test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => { // #given diff --git a/src/plugin-handlers/custom-agent-utils.ts b/src/plugin-handlers/custom-agent-utils.ts new file mode 100644 index 000000000..de96ad901 --- /dev/null +++ b/src/plugin-handlers/custom-agent-utils.ts @@ -0,0 +1,136 @@ +import type { AgentConfig } from "@opencode-ai/sdk"; +import { applyOverrides } from "../agents/builtin-agents/agent-overrides"; +import type { AgentOverrideConfig } from "../agents/types"; +import type { OhMyOpenCodeConfig } from "../config"; +import { getAgentConfigKey } from "../shared/agent-display-names"; +import { AGENT_NAME_MAP } from "../shared/migration"; +import { mergeCategories } from "../shared/merge-categories"; + +const RESERVED_AGENT_KEYS = new Set( + [ + "build", + "plan", + "sisyphus-junior", + "opencode-builder", + ...Object.keys(AGENT_NAME_MAP), + ...Object.values(AGENT_NAME_MAP), + ].map((key) => getAgentConfigKey(key).toLowerCase()), +); + +export type AgentSummary = { + name: string; + description: string; + hidden?: boolean; + disabled?: boolean; + enabled?: boolean; +}; + +export function applyCustomAgentOverrides(params: { + mergedAgents: Record; + userOverrides: OhMyOpenCodeConfig["custom_agents"] | undefined; + builtinOverrideKeys: Set; + mergedCategories: ReturnType; + directory: string; +}): void { + if (!params.userOverrides) return; + + for (const [overrideKey, override] of Object.entries(params.userOverrides)) { + if (!override) continue; + + const normalizedOverrideKey = getAgentConfigKey(overrideKey).toLowerCase(); + if (params.builtinOverrideKeys.has(normalizedOverrideKey)) continue; + + const existingKey = Object.keys(params.mergedAgents).find( + (key) => key.toLowerCase() === overrideKey.toLowerCase() || key.toLowerCase() === normalizedOverrideKey, + ); + if (!existingKey) continue; + + const existingAgent = params.mergedAgents[existingKey]; + if (!existingAgent || typeof existingAgent !== "object") continue; + + params.mergedAgents[existingKey] = applyOverrides( + existingAgent as AgentConfig, + override as AgentOverrideConfig, + params.mergedCategories, + params.directory, + ); + } +} + +export function collectCustomAgentSummariesFromRecord( + agents: Record | undefined, +): AgentSummary[] { + if (!agents) return []; + + const summaries: AgentSummary[] = []; + for (const [name, value] of Object.entries(agents)) { + const normalizedName = getAgentConfigKey(name).toLowerCase(); + if (RESERVED_AGENT_KEYS.has(normalizedName)) continue; + if (!value || typeof value !== "object") continue; + + const agentValue = value as Record; + const description = typeof agentValue.description === "string" ? agentValue.description : ""; + + summaries.push({ + name, + description, + hidden: agentValue.hidden === true, + disabled: agentValue.disabled === true, + enabled: agentValue.enabled === false ? false : true, + }); + } + + return summaries; +} + +export function mergeCustomAgentSummaries(...summaryGroups: AgentSummary[][]): AgentSummary[] { + const merged = new Map(); + + for (const group of summaryGroups) { + for (const summary of group) { + const key = summary.name.toLowerCase(); + if (!merged.has(key)) { + merged.set(key, summary); + continue; + } + + const existing = merged.get(key); + if (!existing) continue; + + const existingDescription = existing.description.trim(); + const incomingDescription = summary.description.trim(); + if (!existingDescription && incomingDescription) { + merged.set(key, summary); + } + } + } + + return Array.from(merged.values()); +} + +export function collectKnownCustomAgentNames( + ...agentGroups: Array | undefined> +): Set { + const knownNames = new Set(); + + for (const group of agentGroups) { + if (!group) continue; + + for (const [name, value] of Object.entries(group)) { + const normalizedName = getAgentConfigKey(name).toLowerCase(); + if (RESERVED_AGENT_KEYS.has(normalizedName)) continue; + if (!value || typeof value !== "object") continue; + + knownNames.add(normalizedName); + } + } + + return knownNames; +} + +export function filterSummariesByKnownNames( + summaries: AgentSummary[], + knownNames: Set, +): AgentSummary[] { + return summaries.filter((summary) => knownNames.has(summary.name.toLowerCase())); +} diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index 3c080ed10..8bd674b7e 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -1,6 +1,7 @@ import type { CategoryConfig } from "../config/schema"; import { PROMETHEUS_PERMISSION, getPrometheusPrompt } from "../agents/prometheus"; import { resolvePromptAppend } from "../agents/builtin-agents/resolve-file-uri"; +import { parseRegisteredAgentSummaries } from "../agents/custom-agent-summaries"; import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; import { fetchAvailableModels, @@ -27,6 +28,7 @@ export async function buildPrometheusAgentConfig(params: { pluginPrometheusOverride: PrometheusOverride | undefined; userCategories: Record | undefined; currentModel: string | undefined; + customAgentSummaries?: unknown; }): Promise> { const categoryConfig = params.pluginPrometheusOverride?.category ? resolveCategoryConfig(params.pluginPrometheusOverride.category, params.userCategories) @@ -65,11 +67,18 @@ export async function buildPrometheusAgentConfig(params: { const maxTokensToUse = params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; + const customAgentCatalog = parseRegisteredAgentSummaries(params.customAgentSummaries) + const customAgentBlock = customAgentCatalog.length > 0 + ? `\n\n\nAvailable custom agents for planning/delegation:\n${customAgentCatalog + .map((agent) => `- ${agent.name}: ${agent.description || "No description provided"}`) + .join("\n")}\n` + : "" + const base: Record = { ...(resolvedModel ? { model: resolvedModel } : {}), ...(variantToUse ? { variant: variantToUse } : {}), mode: "all", - prompt: getPrometheusPrompt(resolvedModel), + prompt: getPrometheusPrompt(resolvedModel) + customAgentBlock, permission: PROMETHEUS_PERMISSION, description: `${(params.configAgentPlan?.description as string) ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, color: (params.configAgentPlan?.color as string) ?? "#FF5722", diff --git a/src/tools/delegate-task/subagent-resolver.test.ts b/src/tools/delegate-task/subagent-resolver.test.ts index 8482c6cf6..6c6e78a3f 100644 --- a/src/tools/delegate-task/subagent-resolver.test.ts +++ b/src/tools/delegate-task/subagent-resolver.test.ts @@ -79,4 +79,56 @@ describe("resolveSubagentExecution", () => { error: "network timeout", }) }) + + test("uses inherited model for custom agents without explicit model", async () => { + //#given + const args = createBaseArgs({ subagent_type: "translator" }) + const executorCtx = createExecutorContext(async () => ({ + data: [{ name: "translator", mode: "subagent" }], + })) + + //#when + const result = await resolveSubagentExecution( + args, + executorCtx, + "sisyphus", + "deep", + "openai/gpt-5.3-codex", + "anthropic/claude-opus-4-6", + ) + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("translator") + expect(result.categoryModel).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex", + }) + }) + + test("uses system default model when inherited model is unavailable", async () => { + //#given + const args = createBaseArgs({ subagent_type: "translator" }) + const executorCtx = createExecutorContext(async () => ({ + data: [{ name: "translator", mode: "subagent" }], + })) + + //#when + const result = await resolveSubagentExecution( + args, + executorCtx, + "sisyphus", + "deep", + undefined, + "anthropic/claude-opus-4-6", + ) + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("translator") + expect(result.categoryModel).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-6", + }) + }) }) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 043243db2..fe3dd92e7 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -15,7 +15,9 @@ export async function resolveSubagentExecution( args: DelegateTaskArgs, executorCtx: ExecutorContext, parentAgent: string | undefined, - categoryExamples: string + categoryExamples: string, + inheritedModel?: string, + systemDefaultModel?: string, ): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> { const { client, agentOverrides } = executorCtx @@ -123,6 +125,16 @@ Create the work plan directly - that's your job as the planning agent.`, if (!categoryModel && matchedAgent.model) { categoryModel = matchedAgent.model } + + if (!categoryModel) { + const fallbackModel = inheritedModel ?? systemDefaultModel + if (fallbackModel) { + const parsedFallback = parseModelString(fallbackModel) + if (parsedFallback) { + categoryModel = parsedFallback + } + } + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) log("[delegate-task] Failed to resolve subagent execution", { diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index 43d1dfd59..0ab4c1baa 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -221,7 +221,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel) } } else { - const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples) + const resolution = await resolveSubagentExecution( + args, + options, + parentContext.agent, + categoryExamples, + inheritedModel, + systemDefaultModel, + ) if (resolution.error) { return resolution.error } From 754a2593f9c778cf8bbd65b2612fd70fd76d8020 Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 19:59:02 +0100 Subject: [PATCH 13/62] chore(schema): regenerate config schema after rebase --- assets/oh-my-opencode.schema.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 3b489819f..5ea581d3e 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3318,21 +3318,6 @@ ], "additionalProperties": false }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "model" - ], - "additionalProperties": false - }, "reasoningEffort": { "type": "string", "enum": [ From fb139a7a0126bbc2f8a9caa17e7ed563668fcbfe Mon Sep 17 00:00:00 2001 From: edxeth Date: Wed, 18 Feb 2026 20:14:47 +0100 Subject: [PATCH 14/62] fix(custom-agents): preserve summary flags during description merge --- src/plugin-handlers/config-handler.test.ts | 62 ++++++++++++++++++++++ src/plugin-handlers/custom-agent-utils.ts | 18 ++++--- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index c91752dfc..264460f1d 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -351,6 +351,68 @@ describe("custom agent overrides", () => { expect(agentsConfig[pKey].prompt).toContain("translator") expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") }) + + test("custom agent summary merge preserves flags when custom_agents adds description", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "", + hidden: true, + disabled: true, + enabled: false, + prompt: "Translate content", + }, + }) + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mock: { calls: unknown[][] } + } + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + description: "Translate and localize locale files", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const firstCallArgs = createBuiltinAgentsMock.mock.calls[0] + const summaries = firstCallArgs[7] as Array<{ + name: string + description: string + hidden?: boolean + disabled?: boolean + enabled?: boolean + }> + const translatorSummary = summaries.find((summary) => summary.name === "translator") + + expect(translatorSummary).toBeDefined() + expect(translatorSummary?.description).toBe("Translate and localize locale files") + expect(translatorSummary?.hidden).toBe(true) + expect(translatorSummary?.disabled).toBe(true) + expect(translatorSummary?.enabled).toBe(false) + }) }) describe("Plan agent demote behavior", () => { diff --git a/src/plugin-handlers/custom-agent-utils.ts b/src/plugin-handlers/custom-agent-utils.ts index de96ad901..eb0568727 100644 --- a/src/plugin-handlers/custom-agent-utils.ts +++ b/src/plugin-handlers/custom-agent-utils.ts @@ -74,9 +74,9 @@ export function collectCustomAgentSummariesFromRecord( summaries.push({ name, description, - hidden: agentValue.hidden === true, - disabled: agentValue.disabled === true, - enabled: agentValue.enabled === false ? false : true, + hidden: typeof agentValue.hidden === "boolean" ? agentValue.hidden : undefined, + disabled: typeof agentValue.disabled === "boolean" ? agentValue.disabled : undefined, + enabled: typeof agentValue.enabled === "boolean" ? agentValue.enabled : undefined, }); } @@ -99,9 +99,15 @@ export function mergeCustomAgentSummaries(...summaryGroups: AgentSummary[][]): A const existingDescription = existing.description.trim(); const incomingDescription = summary.description.trim(); - if (!existingDescription && incomingDescription) { - merged.set(key, summary); - } + + merged.set(key, { + ...existing, + ...summary, + hidden: summary.hidden ?? existing.hidden, + disabled: summary.disabled ?? existing.disabled, + enabled: summary.enabled ?? existing.enabled, + description: incomingDescription || existingDescription, + }); } } From 4f212dbaf95257e5c217db15d5ca12fdc1ac0c3d Mon Sep 17 00:00:00 2001 From: edxeth Date: Tue, 24 Feb 2026 18:49:34 +0100 Subject: [PATCH 15/62] chore(schema): regenerate schema after rebase conflict resolution --- assets/oh-my-opencode.schema.json | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 5ea581d3e..75a2a26f3 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3169,6 +3169,19 @@ "model": { "type": "string" }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, "variant": { "type": "string" }, @@ -3341,6 +3354,30 @@ "type": "string" }, "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false From 8836b61aaafb6add28dc1c56bcca20ac10f7d236 Mon Sep 17 00:00:00 2001 From: edxeth Date: Tue, 24 Feb 2026 19:04:45 +0100 Subject: [PATCH 16/62] test(agents): stabilize provider gating and skill filter tests --- src/agents/utils.test.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 2feb71216..1095fee13 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -242,14 +242,28 @@ describe("createBuiltinAgents with model overrides", () => { test("createBuiltinAgents excludes disabled skills from availableSkills", async () => { // #given const disabledSkills = new Set(["playwright"]) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set([ + "anthropic/claude-opus-4-6", + "opencode/kimi-k2.5-free", + "zai-coding-plan/glm-5", + "opencode/big-pickle", + ]) + ) - // #when - const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills) + try { + // #when + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills) - // #then - expect(agents.sisyphus.prompt).not.toContain("playwright") - expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") - expect(agents.sisyphus.prompt).toContain("git-master") + // #then + expect(agents.sisyphus.prompt).not.toContain("playwright") + expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") + expect(agents.sisyphus.prompt).toContain("git-master") + } finally { + cacheSpy.mockRestore() + fetchSpy.mockRestore() + } }) test("includes custom agents in orchestrator prompts when provided via config", async () => { @@ -589,20 +603,22 @@ describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () => } }) - test("hephaestus is created when github-copilot provider is connected", async () => { + test("hephaestus is not created when only github-copilot provider is connected", async () => { // #given - github-copilot provider has models available const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( new Set(["github-copilot/gpt-5.3-codex"]) ) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) try { // #when const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {}) // #then - expect(agents.hephaestus).toBeDefined() + expect(agents.hephaestus).toBeUndefined() } finally { fetchSpy.mockRestore() + cacheSpy.mockRestore() } }) From f6d5f6f79ff0711e6553cc7226d814a7cc153304 Mon Sep 17 00:00:00 2001 From: Jaden Date: Wed, 25 Feb 2026 17:15:13 +0900 Subject: [PATCH 17/62] fix(model-fallback): apply transformModelForProvider in getNextFallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The getNextFallback function returned raw model names from the hardcoded fallback chain without transforming them for the target provider. For example, github-copilot requires dot notation (claude-sonnet-4.6) but the fallback chain stores hyphen notation (claude-sonnet-4-6). The background-agent retry handler already calls transformModelForProvider correctly, but the sync chat.message hook in model-fallback was missing it β€” a copy-paste omission. Add transformModelForProvider call in getNextFallback and a test verifying github-copilot model name transformation. --- src/hooks/model-fallback/hook.test.ts | 46 +++++++++++++++++++++++++++ src/hooks/model-fallback/hook.ts | 3 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index 4d30d5b0b..3d3b4e749 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -3,12 +3,14 @@ import { beforeEach, describe, expect, test } from "bun:test" import { clearPendingModelFallback, createModelFallbackHook, + setSessionFallbackChain, setPendingModelFallback, } from "./hook" describe("model fallback hook", () => { beforeEach(() => { clearPendingModelFallback("ses_model_fallback_main") + clearPendingModelFallback("ses_model_fallback_ghcp") }) test("applies pending fallback on chat.message by overriding model", async () => { @@ -138,4 +140,48 @@ describe("model fallback hook", () => { expect(toastCalls.length).toBe(1) expect(toastCalls[0]?.title).toBe("Model fallback") }) + + test("transforms model names for github-copilot provider via fallback chain", async () => { + //#given + const sessionID = "ses_model_fallback_ghcp" + clearPendingModelFallback(sessionID) + + const hook = createModelFallbackHook() as unknown as { + "chat.message"?: ( + input: { sessionID: string }, + output: { message: Record; parts: Array<{ type: string; text?: string }> }, + ) => Promise + } + + // Set a custom fallback chain that routes through github-copilot + setSessionFallbackChain(sessionID, [ + { providers: ["github-copilot"], model: "claude-sonnet-4-6" }, + ]) + + const set = setPendingModelFallback( + sessionID, + "Atlas (Plan Executor)", + "github-copilot", + "claude-sonnet-4-6", + ) + expect(set).toBe(true) + + const output = { + message: { + model: { providerID: "github-copilot", modelID: "claude-sonnet-4-6" }, + }, + parts: [{ type: "text", text: "continue" }], + } + + //#when + await hook["chat.message"]?.({ sessionID }, output) + + //#then β€” model name should be transformed from hyphen to dot notation + expect(output.message["model"]).toEqual({ + providerID: "github-copilot", + modelID: "claude-sonnet-4.6", + }) + + clearPendingModelFallback(sessionID) + }) }) diff --git a/src/hooks/model-fallback/hook.ts b/src/hooks/model-fallback/hook.ts index fbe9deabb..bbb01825e 100644 --- a/src/hooks/model-fallback/hook.ts +++ b/src/hooks/model-fallback/hook.ts @@ -3,6 +3,7 @@ import { getAgentConfigKey } from "../../shared/agent-display-names" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache" import { selectFallbackProvider } from "../../shared/model-error-classifier" +import { transformModelForProvider } from "../../shared/provider-model-id-transform" import { log } from "../../shared/logger" import { getTaskToastManager } from "../../features/task-toast-manager" import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message" @@ -145,7 +146,7 @@ export function getNextFallback( return { providerID, - modelID: fallback.model, + modelID: transformModelForProvider(providerID, fallback.model), variant: fallback.variant, } } From 94ff673d40483eeea192bd843d385ae23f670391 Mon Sep 17 00:00:00 2001 From: east-shine Date: Wed, 25 Feb 2026 21:40:28 +0900 Subject: [PATCH 18/62] =?UTF-8?q?test(model-fallback):=20google=20provider?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=EB=AA=85=20=EB=B3=80=ED=99=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google providerμ—μ„œ gemini-3-pro β†’ gemini-3-pro-preview λ³€ν™˜μ΄ getNextFallbackλ₯Ό 톡해 정상 μ μš©λ˜λŠ”μ§€ κ²€μ¦ν•˜λŠ” ν…ŒμŠ€νŠΈ μΆ”κ°€. κΈ°μ‘΄ github-copilot ν…ŒμŠ€νŠΈμ™€ λ™μΌν•œ νŒ¨ν„΄μœΌλ‘œ μž‘μ„±. --- src/hooks/model-fallback/hook.test.ts | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index 3d3b4e749..348f163a1 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -11,6 +11,7 @@ describe("model fallback hook", () => { beforeEach(() => { clearPendingModelFallback("ses_model_fallback_main") clearPendingModelFallback("ses_model_fallback_ghcp") + clearPendingModelFallback("ses_model_fallback_google") }) test("applies pending fallback on chat.message by overriding model", async () => { @@ -184,4 +185,48 @@ describe("model fallback hook", () => { clearPendingModelFallback(sessionID) }) + + test("transforms model names for google provider via fallback chain", async () => { + //#given + const sessionID = "ses_model_fallback_google" + clearPendingModelFallback(sessionID) + + const hook = createModelFallbackHook() as unknown as { + "chat.message"?: ( + input: { sessionID: string }, + output: { message: Record; parts: Array<{ type: string; text?: string }> }, + ) => Promise + } + + // Set a custom fallback chain that routes through google + setSessionFallbackChain(sessionID, [ + { providers: ["google"], model: "gemini-3-pro" }, + ]) + + const set = setPendingModelFallback( + sessionID, + "Oracle", + "google", + "gemini-3-pro", + ) + expect(set).toBe(true) + + const output = { + message: { + model: { providerID: "google", modelID: "gemini-3-pro" }, + }, + parts: [{ type: "text", text: "continue" }], + } + + //#when + await hook["chat.message"]?.({ sessionID }, output) + + //#then β€” model name should be transformed from gemini-3-pro to gemini-3-pro-preview + expect(output.message["model"]).toEqual({ + providerID: "google", + modelID: "gemini-3-pro-preview", + }) + + clearPendingModelFallback(sessionID) + }) }) From 890a737d1e8493238a26f52fa95eb9cd030d7a71 Mon Sep 17 00:00:00 2001 From: Zhiyuan Zheng Date: Thu, 26 Feb 2026 12:38:05 +0800 Subject: [PATCH 19/62] fix(chat-headers): skip x-initiator override for @ai-sdk/github-copilot models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode's copilot fetch wrapper already sets x-initiator based on the actual HTTP request body content. When oh-my-opencode's chat.headers hook overrides it with 'agent', the Copilot API detects a mismatch between the header and the request body and rejects the request with 'invalid initiator'. This matches the approach OpenCode's own chat.headers handler uses (copilot.ts:314) β€” it explicitly skips @ai-sdk/github-copilot models because the fetch wrapper handles x-initiator correctly on its own. --- src/plugin/chat-headers.test.ts | 37 +++++++++++++++++++++++++++++++++ src/plugin/chat-headers.ts | 11 ++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/plugin/chat-headers.test.ts b/src/plugin/chat-headers.test.ts index f2858605d..35de5e006 100644 --- a/src/plugin/chat-headers.test.ts +++ b/src/plugin/chat-headers.test.ts @@ -106,4 +106,41 @@ describe("createChatHeadersHandler", () => { expect(output.headers["x-initiator"]).toBeUndefined() }) + + test("skips x-initiator override when model uses @ai-sdk/github-copilot", async () => { + const handler = createChatHeadersHandler({ + ctx: { + client: { + session: { + message: async () => ({ + data: { + parts: [ + { + type: "text", + text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, + }, + ], + }, + }), + }, + }, + } as never, + }) + const output: { headers: Record } = { headers: {} } + + await handler( + { + sessionID: "ses_4", + provider: { id: "github-copilot" }, + model: { api: { npm: "@ai-sdk/github-copilot" } }, + message: { + id: "msg_4", + role: "user", + }, + }, + output, + ) + + expect(output.headers["x-initiator"]).toBeUndefined() + }) }) diff --git a/src/plugin/chat-headers.ts b/src/plugin/chat-headers.ts index 044ccaf28..9945ac1f1 100644 --- a/src/plugin/chat-headers.ts +++ b/src/plugin/chat-headers.ts @@ -123,6 +123,17 @@ export function createChatHeadersHandler(args: { ctx: PluginContext }): (input: if (!isChatHeadersOutput(output)) return if (!isCopilotProvider(normalizedInput.provider.id)) return + + // Do not override x-initiator when @ai-sdk/github-copilot is active. + // OpenCode's copilot fetch wrapper already sets x-initiator based on + // the actual request body content. Overriding it here causes a mismatch + // that the Copilot API rejects with "invalid initiator". + const model = isRecord(input) && isRecord((input as Record).model) + ? (input as Record).model as Record + : undefined + const api = model && isRecord(model.api) ? model.api as Record : undefined + if (api?.npm === "@ai-sdk/github-copilot") return + if (!(await isOmoInternalMessage(normalizedInput, ctx.client))) return output.headers["x-initiator"] = "agent" From acb51d1702493ccca41e9b2b08e21670a9933b99 Mon Sep 17 00:00:00 2001 From: maou shonen Date: Thu, 26 Feb 2026 09:48:57 +0000 Subject: [PATCH 20/62] fix(comment-checker): bump dependency to ^0.7.0 for --prompt support --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0559493b7..275e92ebe 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@clack/prompts": "^0.11.0", - "@code-yeongyu/comment-checker": "^0.6.1", + "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", "@opencode-ai/plugin": "^1.1.19", "@opencode-ai/sdk": "^1.1.19", From 35edcecd8f1ef214d2528de37565926b3eeb9003 Mon Sep 17 00:00:00 2001 From: ismeth Date: Thu, 26 Feb 2026 16:15:00 +0100 Subject: [PATCH 21/62] fix(agent-usage-reminder): skip reminders for non-orchestrator subagents --- src/hooks/agent-usage-reminder/hook.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index bc7f3243f..ef2a7b3d9 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -6,6 +6,8 @@ import { } from "./storage"; import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; import type { AgentUsageState } from "./types"; +import { getSessionAgent } from "../../features/claude-code-session-state"; +import { getAgentConfigKey } from "../../shared/agent-display-names"; interface ToolExecuteInput { tool: string; @@ -26,6 +28,23 @@ interface EventInput { }; } +/** + * Only orchestrator agents should receive usage reminders. + * Subagents (explore, librarian, oracle, etc.) are the targets of delegation, + * so reminding them to delegate to themselves is counterproductive. + */ +const ORCHESTRATOR_AGENTS = new Set([ + "sisyphus", + "sisyphus-junior", + "atlas", + "hephaestus", + "prometheus", +]); + +function isOrchestratorAgent(agentName: string): boolean { + return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName)); +} + export function createAgentUsageReminderHook(_ctx: PluginInput) { const sessionStates = new Map(); @@ -60,6 +79,12 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { output: ToolExecuteOutput, ) => { const { tool, sessionID } = input; + + const agent = getSessionAgent(sessionID); + if (agent && !isOrchestratorAgent(agent)) { + return; + } + const toolLower = tool.toLowerCase(); if (AGENT_TOOLS.has(toolLower)) { From da1e160add55851d63016f4dcae904e0522e16b8 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 20:01:53 +0100 Subject: [PATCH 22/62] docs(config): document custom_agents behavior and delegation flow --- docs/guide/overview.md | 2 ++ docs/reference/configuration.md | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/guide/overview.md b/docs/guide/overview.md index 242caf490..f0d5fa3ca 100644 --- a/docs/guide/overview.md +++ b/docs/guide/overview.md @@ -68,6 +68,8 @@ User Request When Sisyphus delegates to a subagent, it doesn't pick a model name. It picks a **category** β€” `visual-engineering`, `ultrabrain`, `quick`, `deep`. The category automatically maps to the right model. You touch nothing. +Custom agents are also first-class in this flow. When custom agents are loaded, planning context includes them, so the orchestrator can choose them proactively when appropriate, and you can call them directly on demand via `task(subagent_type="your-agent")`. + For a deep dive into how agents collaborate, see the [Orchestration System Guide](./orchestration.md). --- diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f852fec0d..bb0da5713 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -11,6 +11,7 @@ Complete reference for `oh-my-opencode.jsonc` configuration. This document cover - [Quick Start Example](#quick-start-example) - [Core Concepts](#core-concepts) - [Agents](#agents) + - [Custom Agents (`custom_agents`)](#custom-agents-custom_agents) - [Categories](#categories) - [Model Resolution](#model-resolution) - [Task System](#task-system) @@ -130,6 +131,8 @@ Here's a practical starting configuration: Override built-in agent settings. Available agents: `sisyphus`, `hephaestus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`. +`agents` is intentionally strict and only accepts built-in agent keys. Use `custom_agents` for user-defined agents. + ```json { "agents": { @@ -200,6 +203,59 @@ Control what tools an agent can use: | `doom_loop` | `ask` / `allow` / `deny` | | `external_directory` | `ask` / `allow` / `deny` | +### Custom Agents (`custom_agents`) + +Use `custom_agents` to configure user-defined agents without mixing them into built-in `agents` overrides. + +What this gives you: + +- **Clean separation**: built-ins stay in `agents`, user-defined entries stay in `custom_agents`. +- **Safer config**: keys in `custom_agents` cannot reuse built-in names. +- **First-class orchestration**: loaded custom agents are visible to planner/orchestrator context, so they can be selected proactively during planning and invoked on demand via `task(subagent_type=...)`. +- **Full model controls** for custom agents: `model`, `variant`, `temperature`, `top_p`, `reasoningEffort`, `thinking`, etc. + +Important behavior: + +- `custom_agents` **overrides existing custom agents** loaded at runtime (for example from Claude Code/OpenCode agent sources). +- `custom_agents` does **not** create an agent from thin air by itself; the target custom agent must be present in runtime-loaded agent configs. + +Example: + +```jsonc +{ + "custom_agents": { + "translator": { + "model": "openai/gpt-5.3-codex", + "variant": "high", + "temperature": 0.2, + "prompt_append": "Keep locale placeholders and ICU tokens exactly unchanged." + }, + "reviewer-fast": { + "model": "anthropic/claude-haiku-4-5", + "temperature": 0, + "reasoningEffort": "medium" + } + } +} +``` + +On-demand invocation through task delegation: + +```ts +task( + subagent_type="translator", + load_skills=[], + description="Translate release notes", + prompt="Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", + run_in_background=false, +) +``` + +Migration note: + +- If you previously put custom entries under `agents.*`, move them to `custom_agents.*`. +- Unknown built-in keys under `agents` are reported with migration hints. + ### Categories Domain-specific model delegation used by the `task()` tool. When Sisyphus delegates work, it picks a category, not a model name. From 922ff7f2bcfbaab7ec357dcc29f701edf8f0efcd Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 20:55:58 +0100 Subject: [PATCH 23/62] docs(config): fix custom_agents examples --- docs/reference/configuration.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index bb0da5713..e1f91a2a1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -233,7 +233,10 @@ Example: "reviewer-fast": { "model": "anthropic/claude-haiku-4-5", "temperature": 0, - "reasoningEffort": "medium" + "thinking": { + "type": "enabled", + "budgetTokens": 20000 + } } } } @@ -243,11 +246,13 @@ On-demand invocation through task delegation: ```ts task( - subagent_type="translator", - load_skills=[], - description="Translate release notes", - prompt="Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", - run_in_background=false, + { + subagent_type: "translator", + load_skills: [], + description: "Translate release notes", + prompt: "Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", + run_in_background: false, + }, ) ``` From a5749a1392398b42c393e428b15ae98e5d90b92f Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 21:14:00 +0100 Subject: [PATCH 24/62] fix(custom-agents): align planner catalog and schema validation --- assets/oh-my-opencode.schema.json | 2 +- src/config/schema-document.test.ts | 2 + src/config/schema/agent-overrides.ts | 21 ++++- src/plugin-handlers/agent-config-handler.ts | 51 ++++++----- src/plugin-handlers/config-handler.test.ts | 88 +++++++++++++++++++ .../prometheus-agent-config-builder.ts | 7 ++ 6 files changed, 147 insertions(+), 24 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 30757523b..df796fb78 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3152,7 +3152,7 @@ "type": "object", "propertyNames": { "type": "string", - "pattern": "^(?!(?:build|plan|sisyphus|hephaestus|sisyphus-junior|OpenCode-Builder|prometheus|metis|momus|oracle|librarian|explore|multimodal-looker|atlas)$).+" + "pattern": "^(?!(?:[bB][uU][iI][lL][dD]|[pP][lL][aA][nN]|[sS][iI][sS][yY][pP][hH][uU][sS]|[hH][eE][pP][hH][aA][eE][sS][tT][uU][sS]|[sS][iI][sS][yY][pP][hH][uU][sS]-[jJ][uU][nN][iI][oO][rR]|[oO][pP][eE][nN][cC][oO][dD][eE]-[bB][uU][iI][lL][dD][eE][rR]|[pP][rR][oO][mM][eE][tT][hH][eE][uU][sS]|[mM][eE][tT][iI][sS]|[mM][oO][mM][uU][sS]|[oO][rR][aA][cC][lL][eE]|[lL][iI][bB][rR][aA][rR][iI][aA][nN]|[eE][xX][pP][lL][oO][rR][eE]|[mM][uU][lL][tT][iI][mM][oO][dD][aA][lL]-[lL][oO][oO][kK][eE][rR]|[aA][tT][lL][aA][sS])$).+" }, "additionalProperties": { "type": "object", diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts index 12cc09b87..bc6863f91 100644 --- a/src/config/schema-document.test.ts +++ b/src/config/schema-document.test.ts @@ -23,6 +23,8 @@ describe("schema document generation", () => { expect(agentsSchema?.additionalProperties).toBeFalse() expect(customAgentsSchema).toBeDefined() expect(customPropertyNames?.pattern).toBeDefined() + expect(customPropertyNames?.pattern).toContain("[bB][uU][iI][lL][dD]") + expect(customPropertyNames?.pattern).toContain("[pP][lL][aA][nN]") expect(customAdditionalProperties).toBeDefined() expect(customAgentProperties?.model).toEqual({ type: "string" }) expect(customAgentProperties?.temperature).toEqual( diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index eb5429fba..bc40a7313 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -81,8 +81,27 @@ const RESERVED_CUSTOM_AGENT_NAMES = OverridableAgentNameSchema.options const RESERVED_CUSTOM_AGENT_NAME_SET = new Set( RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.toLowerCase()), ) +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function toCaseInsensitiveLiteralPattern(value: string): string { + return value + .split("") + .map((char) => { + if (/^[A-Za-z]$/.test(char)) { + const lower = char.toLowerCase() + const upper = char.toUpperCase() + return `[${lower}${upper}]` + } + + return escapeRegexLiteral(char) + }) + .join("") +} + const RESERVED_CUSTOM_AGENT_NAME_PATTERN = new RegExp( - `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})$).+`, + `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map(toCaseInsensitiveLiteralPattern).join("|")})$).+`, ) export const CustomAgentOverridesSchema = z diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 7d8893be8..4e61b5c15 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -82,6 +82,15 @@ export async function applyAgentConfig(params: { const browserProvider = params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; const currentModel = params.config.model as string | undefined; + const disabledAgentNames = new Set( + (migratedDisabledAgents ?? []).map((agent) => agent.toLowerCase()), + ); + const filterDisabledAgents = (agents: Record) => + Object.fromEntries( + Object.entries(agents).filter( + ([name]) => !disabledAgentNames.has(name.toLowerCase()), + ), + ); const disabledSkills = new Set(params.pluginConfig.disabled_skills ?? []); const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; @@ -99,19 +108,25 @@ export async function applyAgentConfig(params: { ); const configAgent = params.config.agent as AgentConfigRecord | undefined; + const filteredUserAgents = filterDisabledAgents(userAgents as Record); + const filteredProjectAgents = filterDisabledAgents(projectAgents as Record); + const filteredPluginAgents = filterDisabledAgents(pluginAgents as Record); + const filteredConfigAgentsForSummary = filterDisabledAgents( + (configAgent as Record | undefined) ?? {}, + ); const mergedCategories = mergeCategories(params.pluginConfig.categories) const knownCustomAgentNames = collectKnownCustomAgentNames( - userAgents as Record, - projectAgents as Record, - pluginAgents as Record, - configAgent as Record | undefined, + filteredUserAgents, + filteredProjectAgents, + filteredPluginAgents, + filteredConfigAgentsForSummary, ) const customAgentSummaries = mergeCustomAgentSummaries( - collectCustomAgentSummariesFromRecord(userAgents as Record), - collectCustomAgentSummariesFromRecord(projectAgents as Record), - collectCustomAgentSummariesFromRecord(pluginAgents as Record), - collectCustomAgentSummariesFromRecord(configAgent as Record | undefined), + collectCustomAgentSummariesFromRecord(filteredUserAgents), + collectCustomAgentSummariesFromRecord(filteredProjectAgents), + collectCustomAgentSummariesFromRecord(filteredPluginAgents), + collectCustomAgentSummariesFromRecord(filteredConfigAgentsForSummary), filterSummariesByKnownNames( collectCustomAgentSummariesFromRecord( params.pluginConfig.custom_agents as Record | undefined, @@ -135,14 +150,6 @@ export async function applyAgentConfig(params: { useTaskSystem, disableOmoEnv, ); - const disabledAgentNames = new Set( - (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) - ); - - const filterDisabledAgents = (agents: Record) => - Object.fromEntries( - Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase())) - ); const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -230,9 +237,9 @@ export async function applyAgentConfig(params: { ...Object.fromEntries( Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), ), - ...filterDisabledAgents(userAgents), - ...filterDisabledAgents(projectAgents), - ...filterDisabledAgents(pluginAgents), + ...filteredUserAgents, + ...filteredProjectAgents, + ...filteredPluginAgents, ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -240,9 +247,9 @@ export async function applyAgentConfig(params: { } else { params.config.agent = { ...builtinAgents, - ...filterDisabledAgents(userAgents), - ...filterDisabledAgents(projectAgents), - ...filterDisabledAgents(pluginAgents), + ...filteredUserAgents, + ...filteredProjectAgents, + ...filteredPluginAgents, ...configAgent, }; } diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 6896898c2..3dbe54f4b 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -352,6 +352,94 @@ describe("custom agent overrides", () => { expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") }) + test("prometheus prompt excludes disabled custom agents from catalog", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + disabled_agents: ["translator"], + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).not.toContain("translator") + }) + + test("prometheus custom prompt override still includes custom agent catalog", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + agents: { + prometheus: { + prompt: "Custom planner prompt", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("Custom planner prompt") + expect(agentsConfig[pKey].prompt).toContain("") + expect(agentsConfig[pKey].prompt).toContain("translator") + }) + test("custom agent summary merge preserves flags when custom_agents adds description", async () => { // #given ;(agentLoader.loadUserAgents as any).mockReturnValue({ diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index 8bd674b7e..a2d63c84a 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -103,5 +103,12 @@ export async function buildPrometheusAgentConfig(params: { if (prompt_append && typeof merged.prompt === "string") { merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append); } + if ( + customAgentBlock + && typeof merged.prompt === "string" + && !merged.prompt.includes("") + ) { + merged.prompt = merged.prompt + customAgentBlock; + } return merged; } From 818fdc490c95dde0b56164da207d763fc50fb6c8 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 21:28:00 +0100 Subject: [PATCH 25/62] fix(config): avoid conflicting typo and migration guidance --- src/plugin-config.test.ts | 26 ++++++++++++++++++++++++++ src/plugin-config.ts | 16 ++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 5e2cd08aa..c1bc3441b 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -370,4 +370,30 @@ describe("detectUnknownBuiltinAgentKeys", () => { expect(unknownKeys).toEqual([]) }) + + it("excludes typo keys when explicitly provided", () => { + const rawConfig = { + agents: { + sisyphuss: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"]) + + expect(unknownKeys).toEqual(["translator"]) + }) + + it("excludes typo keys case-insensitively", () => { + const rawConfig = { + agents: { + Sisyphuss: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"]) + + expect(unknownKeys).toEqual(["translator"]) + }) }) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 37b3ff49b..c480f9848 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -81,12 +81,21 @@ export function detectLikelyBuiltinAgentTypos( export function detectUnknownBuiltinAgentKeys( rawConfig: Record, + excludeKeys: string[] = [], ): string[] { const agents = rawConfig.agents; if (!agents || typeof agents !== "object") return []; + const excluded = new Set(excludeKeys.map((key) => key.toLowerCase())); + return Object.keys(agents).filter( - (key) => !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(key.toLowerCase()), + (key) => { + const lower = key.toLowerCase(); + return ( + !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lower) + && !excluded.has(lower) + ); + }, ); } @@ -194,7 +203,10 @@ export function loadConfigFromPath( }); } - const unknownAgentKeys = detectUnknownBuiltinAgentKeys(rawConfig); + const unknownAgentKeys = detectUnknownBuiltinAgentKeys( + rawConfig, + typoWarnings.map((warning) => warning.key), + ); if (unknownAgentKeys.length > 0) { const unknownKeysMsg = unknownAgentKeys.map((key) => `agents.${key}`).join(", "); const migrationHint = "Move custom entries from agents.* to custom_agents.*"; From d7ab5c4d7bd4d1d1ee5a49b0fe8f6793b890eed5 Mon Sep 17 00:00:00 2001 From: edxeth Date: Thu, 26 Feb 2026 21:39:04 +0100 Subject: [PATCH 26/62] refactor(schema): dedupe custom agent override with ref --- assets/oh-my-opencode.schema.json | 441 +++++++++++++++-------------- script/build-schema-document.ts | 38 ++- src/config/schema-document.test.ts | 10 +- 3 files changed, 267 insertions(+), 222 deletions(-) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index df796fb78..b0cce0422 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3155,223 +3155,7 @@ "pattern": "^(?!(?:[bB][uU][iI][lL][dD]|[pP][lL][aA][nN]|[sS][iI][sS][yY][pP][hH][uU][sS]|[hH][eE][pP][hH][aA][eE][sS][tT][uU][sS]|[sS][iI][sS][yY][pP][hH][uU][sS]-[jJ][uU][nN][iI][oO][rR]|[oO][pP][eE][nN][cC][oO][dD][eE]-[bB][uU][iI][lL][dD][eE][rR]|[pP][rR][oO][mM][eE][tT][hH][eE][uU][sS]|[mM][eE][tT][iI][sS]|[mM][oO][mM][uU][sS]|[oO][rR][aA][cC][lL][eE]|[lL][iI][bB][rR][aA][rR][iI][aA][nN]|[eE][xX][pP][lL][oO][rR][eE]|[mM][uU][lL][tT][iI][mM][oO][dD][aA][lL]-[lL][oO][oO][kK][eE][rR]|[aA][tT][lL][aA][sS])$).+" }, "additionalProperties": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "fallback_models": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "variant": { - "type": "string" - }, - "category": { - "type": "string" - }, - "skills": { - "type": "array", - "items": { - "type": "string" - } - }, - "temperature": { - "type": "number", - "minimum": 0, - "maximum": 2 - }, - "top_p": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "prompt": { - "type": "string" - }, - "prompt_append": { - "type": "string" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "mode": { - "type": "string", - "enum": [ - "subagent", - "primary", - "all" - ] - }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{6}$" - }, - "permission": { - "type": "object", - "properties": { - "edit": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "bash": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - } - ] - }, - "webfetch": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "task": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "doom_loop": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - }, - "external_directory": { - "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ] - } - }, - "additionalProperties": false - }, - "maxTokens": { - "type": "number" - }, - "thinking": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "enabled", - "disabled" - ] - }, - "budgetTokens": { - "type": "number" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "reasoningEffort": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "xhigh" - ] - }, - "textVerbosity": { - "type": "string", - "enum": [ - "low", - "medium", - "high" - ] - }, - "providerOptions": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "ultrawork": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - }, - "compaction": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false + "$ref": "#/$defs/agentOverrideConfig" } }, "categories": { @@ -4070,5 +3854,226 @@ } } }, - "additionalProperties": false + "additionalProperties": false, + "$defs": { + "agentOverrideConfig": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/script/build-schema-document.ts b/script/build-schema-document.ts index 17681dcd9..9180c5af9 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,17 +1,53 @@ import * as z from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +function dedupeCustomAgentOverrideSchema(schema: Record): Record { + const rootProperties = asRecord(schema.properties) + const agentsSchema = asRecord(rootProperties?.agents) + const builtInAgentProps = asRecord(agentsSchema?.properties) + const customAgentsSchema = asRecord(rootProperties?.custom_agents) + const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) + + if (!builtInAgentProps || !customAgentsSchema || !customAdditionalProperties) { + return schema + } + + const referenceAgentSchema = asRecord( + builtInAgentProps.build + ?? builtInAgentProps.oracle + ?? builtInAgentProps.explore, + ) + + if (!referenceAgentSchema) { + return schema + } + + const defs = asRecord(schema.$defs) ?? {} + defs.agentOverrideConfig = referenceAgentSchema + schema.$defs = defs + + customAgentsSchema.additionalProperties = { $ref: "#/$defs/agentOverrideConfig" } + + return schema +} + export function createOhMyOpenCodeJsonSchema(): Record { const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { target: "draft-7", unrepresentable: "any", }) - return { + const schema = { $schema: "http://json-schema.org/draft-07/schema#", $id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json", title: "Oh My OpenCode Configuration", description: "Configuration schema for oh-my-opencode plugin", ...jsonSchema, } + + return dedupeCustomAgentOverrideSchema(schema) } diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts index bc6863f91..80bc6d078 100644 --- a/src/config/schema-document.test.ts +++ b/src/config/schema-document.test.ts @@ -16,7 +16,9 @@ describe("schema document generation", () => { const customAgentsSchema = asRecord(rootProperties?.custom_agents) const customPropertyNames = asRecord(customAgentsSchema?.propertyNames) const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) - const customAgentProperties = asRecord(customAdditionalProperties?.properties) + const defs = asRecord(schema.$defs) + const sharedAgentOverrideSchema = asRecord(defs?.agentOverrideConfig) + const sharedAgentProperties = asRecord(sharedAgentOverrideSchema?.properties) // then expect(agentsSchema).toBeDefined() @@ -26,8 +28,10 @@ describe("schema document generation", () => { expect(customPropertyNames?.pattern).toContain("[bB][uU][iI][lL][dD]") expect(customPropertyNames?.pattern).toContain("[pP][lL][aA][nN]") expect(customAdditionalProperties).toBeDefined() - expect(customAgentProperties?.model).toEqual({ type: "string" }) - expect(customAgentProperties?.temperature).toEqual( + expect(customAdditionalProperties?.$ref).toBe("#/$defs/agentOverrideConfig") + expect(sharedAgentOverrideSchema).toBeDefined() + expect(sharedAgentProperties?.model).toEqual({ type: "string" }) + expect(sharedAgentProperties?.temperature).toEqual( expect.objectContaining({ type: "number" }), ) }) From 1c6d384f144cf860497f5d4bdd05d430d7d4c4c7 Mon Sep 17 00:00:00 2001 From: 1noilimrev Date: Fri, 27 Feb 2026 14:39:07 +0900 Subject: [PATCH 27/62] fix(hooks): use terminal-notifier for macOS notification click-to-focus --- src/hooks/session-notification-sender.ts | 14 ++++++++++++++ src/hooks/session-notification-utils.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 8c5cf1df7..4ac0c4d8c 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -7,6 +7,7 @@ import { getAfplayPath, getPaplayPath, getAplayPath, + getTerminalNotifierPath, } from "./session-notification-utils" import { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting" @@ -39,6 +40,19 @@ export async function sendSessionNotification( ): Promise { switch (platform) { case "darwin": { + // Try terminal-notifier first β€” deterministic click-to-focus + const terminalNotifierPath = await getTerminalNotifierPath() + if (terminalNotifierPath) { + const bundleId = process.env.__CFBundleIdentifier + const args = [terminalNotifierPath, "-title", title, "-message", message] + if (bundleId) { + args.push("-activate", bundleId) + } + await ctx.$`${args}`.catch(() => {}) + break + } + + // Fallback: osascript (click may open Finder instead of terminal) const osascriptPath = await getOsascriptPath() if (!osascriptPath) return diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index 0c09fd8f8..5f9d572fb 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -32,11 +32,13 @@ export const getPowershellPath = createCommandFinder("powershell") export const getAfplayPath = createCommandFinder("afplay") export const getPaplayPath = createCommandFinder("paplay") export const getAplayPath = createCommandFinder("aplay") +export const getTerminalNotifierPath = createCommandFinder("terminal-notifier") export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { getOsascriptPath().catch(() => {}) getAfplayPath().catch(() => {}) + getTerminalNotifierPath().catch(() => {}) } else if (platform === "linux") { getNotifySendPath().catch(() => {}) getPaplayPath().catch(() => {}) From 88bf8268f573e286e1b1d10ccaf0a7d3bfc18a04 Mon Sep 17 00:00:00 2001 From: 1noilimrev Date: Fri, 27 Feb 2026 14:48:34 +0900 Subject: [PATCH 28/62] test(hooks): add darwin notification backend selection tests --- src/hooks/session-notification.test.ts | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index cf895ba98..3bee39f0b 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -365,4 +365,99 @@ describe("session-notification", () => { // then - only one notification should be sent expect(notificationCalls).toHaveLength(1) }) + + test("should use terminal-notifier with -activate when available on darwin", async () => { + // given - terminal-notifier is available and __CFBundleIdentifier is set + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + const originalEnv = process.env.__CFBundleIdentifier + process.env.__CFBundleIdentifier = "com.mitchellh.ghostty" + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - notification uses terminal-notifier with -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).toContain("-activate") + expect(tnCall).toContain("com.mitchellh.ghostty") + + // cleanup + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } else { + delete process.env.__CFBundleIdentifier + } + }) + + test("should fall back to osascript when terminal-notifier is not available", async () => { + // given - terminal-notifier is NOT available + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) + spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - notification uses osascript (fallback) + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const osascriptCall = notifyCalls.find(c => c.includes("osascript")) + expect(osascriptCall).toBeDefined() + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeUndefined() + }) + + test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { + // given - terminal-notifier available but no bundle ID + spyOn(sender, "sendSessionNotification").mockRestore() + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + const originalEnv = process.env.__CFBundleIdentifier + delete process.env.__CFBundleIdentifier + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - terminal-notifier used but without -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).not.toContain("-activate") + + // cleanup + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } + }) + }) From fbe3b5423db3336fcfe0dd3d4ee6dece5e2f75c9 Mon Sep 17 00:00:00 2001 From: 1noilimrev Date: Fri, 27 Feb 2026 15:30:13 +0900 Subject: [PATCH 29/62] refactor(test): extract shared mock helper and add try-finally for env cleanup --- src/hooks/session-notification.test.ts | 86 +++++++++++--------------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index 3bee39f0b..9d9c4706b 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -366,9 +366,7 @@ describe("session-notification", () => { expect(notificationCalls).toHaveLength(1) }) - test("should use terminal-notifier with -activate when available on darwin", async () => { - // given - terminal-notifier is available and __CFBundleIdentifier is set - spyOn(sender, "sendSessionNotification").mockRestore() + function createSenderMockCtx() { const notifyCalls: string[] = [] const mockCtx = { $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { @@ -379,41 +377,40 @@ describe("session-notification", () => { return { stdout: "", stderr: "", exitCode: 0 } }, } as any + return { mockCtx, notifyCalls } + } + + test("should use terminal-notifier with -activate when available on darwin", async () => { + // given - terminal-notifier is available and __CFBundleIdentifier is set + spyOn(sender, "sendSessionNotification").mockRestore() + const { mockCtx, notifyCalls } = createSenderMockCtx() spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") const originalEnv = process.env.__CFBundleIdentifier process.env.__CFBundleIdentifier = "com.mitchellh.ghostty" - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + try { + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - // then - notification uses terminal-notifier with -activate flag - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeDefined() - expect(tnCall).toContain("-activate") - expect(tnCall).toContain("com.mitchellh.ghostty") - - // cleanup - if (originalEnv !== undefined) { - process.env.__CFBundleIdentifier = originalEnv - } else { - delete process.env.__CFBundleIdentifier + // then - notification uses terminal-notifier with -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).toContain("-activate") + expect(tnCall).toContain("com.mitchellh.ghostty") + } finally { + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } else { + delete process.env.__CFBundleIdentifier + } } }) test("should fall back to osascript when terminal-notifier is not available", async () => { // given - terminal-notifier is NOT available spyOn(sender, "sendSessionNotification").mockRestore() - const notifyCalls: string[] = [] - const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - notifyCalls.push(cmdStr) - return { stdout: "", stderr: "", exitCode: 0 } - }, - } as any + const { mockCtx, notifyCalls } = createSenderMockCtx() spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") @@ -431,33 +428,24 @@ describe("session-notification", () => { test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { // given - terminal-notifier available but no bundle ID spyOn(sender, "sendSessionNotification").mockRestore() - const notifyCalls: string[] = [] - const mockCtx = { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - notifyCalls.push(cmdStr) - return { stdout: "", stderr: "", exitCode: 0 } - }, - } as any + const { mockCtx, notifyCalls } = createSenderMockCtx() spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") const originalEnv = process.env.__CFBundleIdentifier delete process.env.__CFBundleIdentifier - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + try { + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - // then - terminal-notifier used but without -activate flag - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeDefined() - expect(tnCall).not.toContain("-activate") - - // cleanup - if (originalEnv !== undefined) { - process.env.__CFBundleIdentifier = originalEnv + // then - terminal-notifier used but without -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).not.toContain("-activate") + } finally { + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } } }) - }) From d09cf56e15f5ebb7208418bedd4b3980d9282b43 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Fri, 27 Feb 2026 13:54:50 +0800 Subject: [PATCH 30/62] =?UTF-8?q?feat(delegate-task):=20=E2=9A=99=EF=B8=8F?= =?UTF-8?q?=20make=20sync=20subagent=20timeout=20configurable=20via=20sync?= =?UTF-8?q?PollTimeoutMs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to set `background_task.syncPollTimeoutMs` in config to override the default 10-minute sync subagent timeout. Affects sync task, sync continuation, and unstable agent task paths. Minimum value: 60000ms (1 minute). Co-authored-by: Wine Fox --- assets/oh-my-opencode.schema.json | 4 + src/config/schema/background-task.test.ts | 51 +++++ src/config/schema/background-task.ts | 1 + src/plugin/tool-registry.ts | 1 + src/tools/delegate-task/executor-types.ts | 1 + src/tools/delegate-task/sync-continuation.ts | 4 +- .../delegate-task/sync-poll-timeout.test.ts | 176 ++++++++++++++++++ .../delegate-task/sync-session-poller.test.ts | 2 +- .../delegate-task/sync-session-poller.ts | 7 +- src/tools/delegate-task/sync-task.ts | 4 +- src/tools/delegate-task/timing.ts | 2 + src/tools/delegate-task/tools.test.ts | 65 +++++-- src/tools/delegate-task/types.ts | 1 + .../delegate-task/unstable-agent-task.ts | 6 +- 14 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 src/config/schema/background-task.test.ts create mode 100644 src/tools/delegate-task/sync-poll-timeout.test.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 2c4819e7f..ed5f81f5f 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3685,6 +3685,10 @@ "messageStalenessTimeoutMs": { "type": "number", "minimum": 60000 + }, + "syncPollTimeoutMs": { + "type": "number", + "minimum": 60000 } }, "additionalProperties": false diff --git a/src/config/schema/background-task.test.ts b/src/config/schema/background-task.test.ts new file mode 100644 index 000000000..2ca225864 --- /dev/null +++ b/src/config/schema/background-task.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test" +import { ZodError } from "zod/v4" +import { BackgroundTaskConfigSchema } from "./background-task" + +describe("BackgroundTaskConfigSchema", () => { + describe("syncPollTimeoutMs", () => { + describe("#given valid syncPollTimeoutMs (120000)", () => { + test("#when parsed #then returns correct value", () => { + const result = BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: 120000 }) + + expect(result.syncPollTimeoutMs).toBe(120000) + }) + }) + + describe("#given syncPollTimeoutMs below minimum (59999)", () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: 59999 }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + + describe("#given syncPollTimeoutMs not provided", () => { + test("#when parsed #then field is undefined", () => { + const result = BackgroundTaskConfigSchema.parse({}) + + expect(result.syncPollTimeoutMs).toBeUndefined() + }) + }) + + describe('#given syncPollTimeoutMs is non-number ("abc")', () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: "abc" }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + }) +}) diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts index 233fe2863..b955de6b5 100644 --- a/src/config/schema/background-task.ts +++ b/src/config/schema/background-task.ts @@ -8,6 +8,7 @@ export const BackgroundTaskConfigSchema = z.object({ staleTimeoutMs: z.number().min(60000).optional(), /** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */ messageStalenessTimeoutMs: z.number().min(60000).optional(), + syncPollTimeoutMs: z.number().min(60000).optional(), }) export type BackgroundTaskConfig = z.infer diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 21d7901f4..ddcc227ec 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -67,6 +67,7 @@ export function createToolRegistry(args: { disabledSkills: skillContext.disabledSkills, availableCategories, availableSkills: skillContext.availableSkills, + syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs, onSyncSessionCreated: async (event) => { log("[index] onSyncSessionCreated callback", { sessionID: event.sessionID, diff --git a/src/tools/delegate-task/executor-types.ts b/src/tools/delegate-task/executor-types.ts index 136f6dbf2..ad8c01879 100644 --- a/src/tools/delegate-task/executor-types.ts +++ b/src/tools/delegate-task/executor-types.ts @@ -12,6 +12,7 @@ export interface ExecutorContext { browserProvider?: BrowserAutomationProvider agentOverrides?: AgentOverrides onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise + syncPollTimeoutMs?: number } export interface ParentContext { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index b31e19508..a65b20613 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -18,7 +18,7 @@ export async function executeSyncContinuation( executorCtx: ExecutorContext, deps: SyncContinuationDeps = syncContinuationDeps ): Promise { - const { client } = executorCtx + const { client, syncPollTimeoutMs } = executorCtx const toastManager = getTaskToastManager() const taskId = `resume_sync_${args.session_id!.slice(0, 8)}` const startTime = new Date() @@ -112,7 +112,7 @@ export async function executeSyncContinuation( toastManager, taskId, anchorMessageCount, - }) + }, syncPollTimeoutMs) if (pollError) { return pollError } diff --git a/src/tools/delegate-task/sync-poll-timeout.test.ts b/src/tools/delegate-task/sync-poll-timeout.test.ts new file mode 100644 index 000000000..b89b7887a --- /dev/null +++ b/src/tools/delegate-task/sync-poll-timeout.test.ts @@ -0,0 +1,176 @@ +declare const require: (name: string) => any +const { describe, test, expect, beforeEach, afterEach } = require("bun:test") +import { __setTimingConfig, __resetTimingConfig, DEFAULT_SYNC_POLL_TIMEOUT_MS } from "./timing" + +function createMockCtx(aborted = false) { + const controller = new AbortController() + if (aborted) controller.abort() + return { + sessionID: "parent-session", + messageID: "parent-message", + agent: "test-agent", + abort: controller.signal, + } +} + +function createNeverCompleteClient(sessionID: string) { + return { + session: { + messages: async () => ({ + data: [{ info: { id: "msg_001", role: "user", time: { created: 1000 } } }], + }), + status: async () => ({ data: { [sessionID]: { type: "idle" } } }), + }, + } +} + +async function withMockedDateNow(stepMs: number, run: () => Promise) { + const originalDateNow = Date.now + let now = 0 + + Date.now = () => { + const current = now + now += stepMs + return current + } + + try { + await run() + } finally { + Date.now = originalDateNow + } +} + +describe("syncPollTimeoutMs threading", () => { + beforeEach(() => { + __setTimingConfig({ + POLL_INTERVAL_MS: 10, + MIN_STABILITY_TIME_MS: 0, + STABILITY_POLLS_REQUIRED: 1, + MAX_POLL_TIME_MS: 5000, + }) + }) + + afterEach(() => { + __resetTimingConfig() + }) + + describe("#given pollSyncSession timeoutMs input", () => { + describe("#when custom timeout is provided", () => { + test("#then custom timeout value is used", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_custom") + + await withMockedDateNow(60_000, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_custom", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }, 120_000) + + expect(result).toBe("Poll timeout reached after 120000ms for session ses_custom") + }) + }) + }) + + describe("#when timeoutMs is omitted", () => { + test("#then default timeout constant is used", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_default") + + expect(DEFAULT_SYNC_POLL_TIMEOUT_MS).toBe(600_000) + + await withMockedDateNow(300_000, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_default", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }) + + expect(result).toBe(`Poll timeout reached after ${DEFAULT_SYNC_POLL_TIMEOUT_MS}ms for session ses_default`) + }) + }) + }) + + describe("#when timeoutMs is lower than minimum guard", () => { + test("#then minimum 50ms timeout is enforced", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_guard") + + await withMockedDateNow(25, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_guard", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }, 10) + + expect(result).toBe("Poll timeout reached after 50ms for session ses_guard") + }) + }) + }) + }) + + describe("#given unstable-agent-task path", () => { + describe("#when syncPollTimeoutMs is set in executor context", () => { + test("#then unstable path uses configured timeout budget", async () => { + const { executeUnstableAgentTask } = require("./unstable-agent-task") + + let statusCallCount = 0 + const mockClient = { + session: { + status: async () => { + statusCallCount++ + return { data: { ses_unstable: { type: "idle" } } } + }, + messages: async () => ({ + data: [ + { + info: { id: "msg_001", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "unstable path done" }], + }, + ], + }), + }, + } + + const mockManager = { + launch: async () => ({ id: "task_001", sessionID: "ses_unstable", status: "running" }), + getTask: () => ({ id: "task_001", sessionID: "ses_unstable", status: "running" }), + } + + const result = await executeUnstableAgentTask( + { + description: "unstable timeout threading", + prompt: "run", + category: "unspecified-low", + run_in_background: false, + load_skills: [], + command: undefined, + }, + createMockCtx(), + { + manager: mockManager, + client: mockClient, + syncPollTimeoutMs: 0, + }, + { + sessionID: "parent-session", + messageID: "parent-message", + model: "gpt-test", + agent: "test-agent", + }, + "test-agent", + undefined, + undefined, + "gpt-test" + ) + + expect(statusCallCount).toBe(0) + expect(result).toContain("SUPERVISED TASK COMPLETED SUCCESSFULLY") + }) + }) + }) +}) diff --git a/src/tools/delegate-task/sync-session-poller.test.ts b/src/tools/delegate-task/sync-session-poller.test.ts index 61defaf87..279116a17 100644 --- a/src/tools/delegate-task/sync-session-poller.test.ts +++ b/src/tools/delegate-task/sync-session-poller.test.ts @@ -273,7 +273,7 @@ describe("pollSyncSession", () => { agentToUse: "test-agent", toastManager: null, taskId: undefined, - }) + }, 0) //#then - timeout returns error string expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout") diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 9c8cb2567..3d5e88df2 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -1,6 +1,6 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" -import { getTimingConfig } from "./timing" +import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing" import { log } from "../../shared/logger" import { normalizeSDKResponse } from "../../shared" @@ -32,10 +32,11 @@ export async function pollSyncSession( toastManager: { removeTask: (id: string) => void } | null | undefined taskId: string | undefined anchorMessageCount?: number - } + }, + timeoutMs?: number ): Promise { const syncTiming = getTimingConfig() - const maxPollTimeMs = Math.max(syncTiming.MAX_POLL_TIME_MS, 50) + const maxPollTimeMs = Math.max(timeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS, 50) const pollStart = Date.now() let pollCount = 0 let timedOut = false diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index f384b370f..2ff600d09 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -23,7 +23,7 @@ export async function executeSyncTask( fallbackChain?: import("../../shared/model-requirements").FallbackEntry[], deps: SyncTaskDeps = syncTaskDeps ): Promise { - const { client, directory, onSyncSessionCreated } = executorCtx + const { client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx const toastManager = getTaskToastManager() let taskId: string | undefined let syncSessionID: string | undefined @@ -117,7 +117,7 @@ export async function executeSyncTask( agentToUse, toastManager, taskId, - }) + }, syncPollTimeoutMs) if (pollError) { return pollError } diff --git a/src/tools/delegate-task/timing.ts b/src/tools/delegate-task/timing.ts index 5510d4e2d..5b404f3b8 100644 --- a/src/tools/delegate-task/timing.ts +++ b/src/tools/delegate-task/timing.ts @@ -6,6 +6,8 @@ let WAIT_FOR_SESSION_TIMEOUT_MS = 30000 let MAX_POLL_TIME_MS = 10 * 60 * 1000 let SESSION_CONTINUATION_STABILITY_MS = 5000 +export const DEFAULT_SYNC_POLL_TIMEOUT_MS = 600_000 + export function getTimingConfig() { return { POLL_INTERVAL_MS, diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 8c0b01acf..bb0b5ec29 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1357,29 +1357,58 @@ describe("sisyphus-task", () => { return { data: {} } }) + const baseTime = Date.now() + const initialMessages = [ + { + info: { + id: "msg_001", + role: "user", + agent: "sisyphus-junior", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + variant: "max", + time: { created: baseTime }, + }, + parts: [{ type: "text", text: "previous message" }], + }, + { + info: { id: "msg_002", role: "assistant", time: { created: baseTime + 1 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Completed." }], + }, + ] + + const messagesCallCounts: Record = {} + const mockClient = { session: { prompt: promptMock, promptAsync: promptMock, - messages: async () => ({ - data: [ - { - info: { - id: "msg_001", - role: "user", - agent: "sisyphus-junior", - model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, - variant: "max", - time: { created: Date.now() }, + messages: async (input: any) => { + const sessionID = input?.path?.id + if (typeof sessionID !== "string") { + return { data: [] } + } + + const callCount = (messagesCallCounts[sessionID] ?? 0) + 1 + messagesCallCounts[sessionID] = callCount + + if (sessionID !== "ses_var_test") { + return { data: [] } + } + + if (callCount === 1) { + return { data: initialMessages } + } + + return { + data: [ + ...initialMessages, + { + info: { id: "msg_003", role: "assistant", time: { created: baseTime + 2 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Continued." }], }, - parts: [{ type: "text", text: "previous message" }], - }, - { - info: { id: "msg_002", role: "assistant", time: { created: Date.now() + 1 }, finish: "end_turn" }, - parts: [{ type: "text", text: "Completed." }], - }, - ], - }), + ], + } + }, status: async () => ({ data: { "ses_var_test": { type: "idle" } } }), }, config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index 7c749d208..c51a1bde1 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -68,6 +68,7 @@ export interface DelegateTaskToolOptions { availableSkills?: AvailableSkill[] agentOverrides?: AgentOverrides onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise + syncPollTimeoutMs?: number } export interface BuildSystemContentInput { diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index c0972e7bd..ca6c38ee1 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -1,6 +1,6 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types" -import { getTimingConfig } from "./timing" +import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing" import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" @@ -17,7 +17,7 @@ export async function executeUnstableAgentTask( systemContent: string | undefined, actualModel: string | undefined ): Promise { - const { manager, client } = executorCtx + const { manager, client, syncPollTimeoutMs } = executorCtx try { const task = await manager.launch({ @@ -80,7 +80,7 @@ export async function executeUnstableAgentTask( let stablePolls = 0 let terminalStatus: { status: string; error?: string } | undefined - while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) { + while (Date.now() - pollStart < (syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS)) { if (ctx.abort?.aborted) { return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}` } From c1eaf5fcabb32752c742e834cbd800a3d1c1af4e Mon Sep 17 00:00:00 2001 From: YLRong Date: Fri, 27 Feb 2026 17:06:40 +0800 Subject: [PATCH 31/62] fix: remove misleading hint from replace pos-only description The hint '(MOST COMMON for single-line edits)' misleads agents into thinking pos-only replace is the default behavior. When agents want to replace multiple lines but only specify pos without end, the tool only replaces one line, causing duplicate code from retained lines. --- src/tools/hashline-edit/tool-description.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index 2d452ccfa..c8a566860 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -34,7 +34,7 @@ FILE CREATION: CRITICAL: only unanchored append/prepend can create a missing file. OPERATION CHOICE: - replace with pos only -> replace one line at pos (MOST COMMON for single-line edits) + replace with pos only -> replace one line at pos replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits) append with pos/end anchor -> insert after that anchor prepend with pos/end anchor -> insert before that anchor From 83c024dd663635c004eb1aabeb1258a4af578690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mert=20Y=C4=B1ld=C4=B1r=C4=B1m?= Date: Fri, 27 Feb 2026 13:39:17 +0300 Subject: [PATCH 32/62] fix: remove console.warn that leaks into TUI textbox getConfigContext() emitted a console.warn when called before initConfigContext() completed. Since initConfigContext runs async (spawns opencode --version subprocess), other modules calling getConfigDir/getConfigJson could trigger this warning during startup. The fallback behavior is intentional and safe (defaults to standard CLI paths), but console.warn writes to stderr which the TUI captures, causing the warning to render inside the user's textbox. Fixes #2183 --- src/cli/config-manager/config-context.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/cli/config-manager/config-context.ts b/src/cli/config-manager/config-context.ts index 67448f29a..78eb88d77 100644 --- a/src/cli/config-manager/config-context.ts +++ b/src/cli/config-manager/config-context.ts @@ -19,9 +19,6 @@ export function initConfigContext(binary: OpenCodeBinaryType, version: string | export function getConfigContext(): ConfigContext { if (!configContext) { - if (process.env.NODE_ENV !== "production") { - console.warn("[config-context] getConfigContext() called before initConfigContext(); defaulting to CLI paths.") - } const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) configContext = { binary: "opencode", version: null, paths } } From 09fd131f24328a746737909f3fd7d841ce46a77a Mon Sep 17 00:00:00 2001 From: David Hardy Date: Fri, 27 Feb 2026 14:46:12 +0000 Subject: [PATCH 33/62] fix: initialize config context in plugin runtime to prevent warnings The auto-update checker hook calls getConfigDir() which requires the config context to be initialized via initConfigContext(). When running in the OpenCode TUI plugin runtime (vs CLI), this initialization never happened, causing the warning: "getConfigContext() called before initConfigContext(); defaulting to CLI paths." This warning would appear when opening folders containing .mulch or .beads directories because the lifecycle plugins triggered the auto-update checker. Fix: Call initConfigContext("opencode", null) at plugin startup to ensure the config context is properly initialized for all hooks and utilities. Fixes upstream issue where TUI users see spurious bun install warnings. --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index bba719041..3ae22411c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { initConfigContext } from "./cli/config-manager/config-context" import type { Plugin } from "@opencode-ai/plugin" import type { HookName } from "./config" @@ -14,6 +15,8 @@ import { injectServerAuthIntoClient, log } from "./shared" import { startTmuxCheck } from "./tools" const OhMyOpenCodePlugin: Plugin = async (ctx) => { + // Initialize config context for plugin runtime (prevents warnings from hooks) + initConfigContext("opencode", null) log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory, }) From deb904bbc4b25da3a6ff3686b19be071f8bf6739 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:03:20 -0700 Subject: [PATCH 34/62] fix(skill-mcp): clarify builtin MCP error hint --- src/tools/skill-mcp/builtin-mcp-hint.test.ts | 45 ++++++++++++++++++++ src/tools/skill-mcp/constants.ts | 6 +++ src/tools/skill-mcp/tools.ts | 17 +++++++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/tools/skill-mcp/builtin-mcp-hint.test.ts diff --git a/src/tools/skill-mcp/builtin-mcp-hint.test.ts b/src/tools/skill-mcp/builtin-mcp-hint.test.ts new file mode 100644 index 000000000..6f96b339d --- /dev/null +++ b/src/tools/skill-mcp/builtin-mcp-hint.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "bun:test" + +import { SkillMcpManager } from "../../features/skill-mcp-manager" +import { createSkillMcpTool } from "./tools" + +const mockContext = { + sessionID: "test-session", + messageID: "msg-1", + agent: "test-agent", + directory: "/test", + worktree: "/test", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, +} + +describe("skill_mcp builtin MCP hint", () => { + it("returns builtin hint for context7", async () => { + const tool = createSkillMcpTool({ + manager: new SkillMcpManager(), + getLoadedSkills: () => [], + getSessionID: () => "session", + }) + + await expect( + tool.execute({ mcp_name: "context7", tool_name: "resolve-library-id" }, mockContext), + ).rejects.toThrow(/builtin MCP/) + + await expect( + tool.execute({ mcp_name: "context7", tool_name: "resolve-library-id" }, mockContext), + ).rejects.toThrow(/context7_resolve-library-id/) + }) + + it("keeps skill-loading hint for unknown MCP names", async () => { + const tool = createSkillMcpTool({ + manager: new SkillMcpManager(), + getLoadedSkills: () => [], + getSessionID: () => "session", + }) + + await expect( + tool.execute({ mcp_name: "unknown-mcp", tool_name: "x" }, mockContext), + ).rejects.toThrow(/Load the skill first/) + }) +}) diff --git a/src/tools/skill-mcp/constants.ts b/src/tools/skill-mcp/constants.ts index 4df4f4d40..e2d13cf0b 100644 --- a/src/tools/skill-mcp/constants.ts +++ b/src/tools/skill-mcp/constants.ts @@ -1,3 +1,9 @@ export const SKILL_MCP_TOOL_NAME = "skill_mcp" export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.` + +export const BUILTIN_MCP_TOOL_HINTS: Record = { + context7: ["context7_resolve-library-id", "context7_query-docs"], + websearch: ["websearch_web_search_exa"], + grep_app: ["grep_app_searchGitHub"], +} diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts index 96dddaa75..9791501fe 100644 --- a/src/tools/skill-mcp/tools.ts +++ b/src/tools/skill-mcp/tools.ts @@ -1,5 +1,5 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { SKILL_MCP_DESCRIPTION } from "./constants" +import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants" import type { SkillMcpArgs } from "./types" import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" @@ -71,6 +71,16 @@ function formatAvailableMcps(skills: LoadedSkill[]): string { return mcps.length > 0 ? mcps.join("\n") : " (none found)" } +function formatBuiltinMcpHint(mcpName: string): string | null { + const nativeTools = BUILTIN_MCP_TOOL_HINTS[mcpName] + if (!nativeTools) return null + return ( + `"${mcpName}" is a builtin MCP, not a skill MCP.\n` + + `Use the native tools directly:\n` + + nativeTools.map((toolName) => ` - ${toolName}`).join("\n") + ) +} + function parseArguments(argsJson: string | Record | undefined): Record { if (!argsJson) return {} if (typeof argsJson === "object" && argsJson !== null) { @@ -132,6 +142,11 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition const found = findMcpServer(args.mcp_name, skills) if (!found) { + const builtinHint = formatBuiltinMcpHint(args.mcp_name) + if (builtinHint) { + throw new Error(builtinHint) + } + throw new Error( `MCP server "${args.mcp_name}" not found.\n\n` + `Available MCP servers in loaded skills:\n` + From e2e3d110b75411ed6146a6472083dff5e61f14ac Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:38:52 -0700 Subject: [PATCH 35/62] feat(start-work): add auto_commit config option Add start_work.auto_commit configuration option to allow users to disable the automatic commit step in the /start-work workflow. When auto_commit is false: - STEP 8: COMMIT ATOMIC UNIT is removed from orchestrator reminder - STEP 9: PROCEED TO NEXT TASK becomes STEP 8 Resolves #2197 --- assets/oh-my-opencode.schema.json | 13 ++++++++++ src/config/AGENTS.md | 9 ++++--- src/config/schema/oh-my-opencode-config.ts | 2 ++ src/config/schema/start-work.ts | 8 +++++++ src/hooks/atlas/atlas-hook.ts | 3 ++- src/hooks/atlas/tool-execute-after.ts | 11 ++++----- src/hooks/atlas/types.ts | 2 ++ src/hooks/atlas/verification-reminders.ts | 24 ++++++++++++------- src/plugin/hooks/create-continuation-hooks.ts | 1 + 9 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 src/config/schema/start-work.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 16114f11d..025ec186c 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3837,6 +3837,19 @@ }, "additionalProperties": false }, + "start_work": { + "type": "object", + "properties": { + "auto_commit": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "auto_commit" + ], + "additionalProperties": false + }, "_migrations": { "type": "array", "items": { diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 83a8830a3..8838e91e4 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional β€” omitted fields use plugin defaults. +7ZB|22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional β€” omitted fields use plugin defaults. ## SCHEMA TREE @@ -31,12 +31,15 @@ config/schema/ β”œβ”€β”€ background-task.ts # Concurrency limits per model/provider β”œβ”€β”€ babysitting.ts # Unstable agent monitoring β”œβ”€β”€ dynamic-context-pruning.ts # Context pruning settings +β”œβ”€β”€ start-work.ts # StartWorkConfigSchema (auto_commit) +└── internal/permission.ts # AgentPermissionSchema +β”œβ”€β”€ start-work.ts # StartWorkConfigSchema (auto_commit) └── internal/permission.ts # AgentPermissionSchema ``` -## ROOT SCHEMA FIELDS (27) +## ROOT SCHEMA FIELDS (28) -`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `hashline_edit`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `_migrations` +`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `hashline_edit`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `start_work`, `_migrations` ## AGENT OVERRIDE FIELDS (21) diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 52e7d461e..cf98ccf1c 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -18,6 +18,7 @@ import { SkillsConfigSchema } from "./skills" import { SisyphusConfigSchema } from "./sisyphus" import { SisyphusAgentConfigSchema } from "./sisyphus-agent" import { TmuxConfigSchema } from "./tmux" +import { StartWorkConfigSchema } from "./start-work" import { WebsearchConfigSchema } from "./websearch" export const OhMyOpenCodeConfigSchema = z.object({ @@ -60,6 +61,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ websearch: WebsearchConfigSchema.optional(), tmux: TmuxConfigSchema.optional(), sisyphus: SisyphusConfigSchema.optional(), + start_work: StartWorkConfigSchema.optional(), /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ _migrations: z.array(z.string()).optional(), }) diff --git a/src/config/schema/start-work.ts b/src/config/schema/start-work.ts new file mode 100644 index 000000000..7daae0c3d --- /dev/null +++ b/src/config/schema/start-work.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const StartWorkConfigSchema = z.object({ + /** Enable auto-commit after each atomic task completion (default: true) */ + auto_commit: z.boolean().default(true), +}) + +export type StartWorkConfig = z.infer diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index 94a6470e9..97d0842d7 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -7,6 +7,7 @@ import type { AtlasHookOptions, SessionState } from "./types" export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { const sessions = new Map() const pendingFilePaths = new Map() + const autoCommit = options?.autoCommit ?? true function getState(sessionID: string): SessionState { let state = sessions.get(sessionID) @@ -20,6 +21,6 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { return { handler: createAtlasEventHandler({ ctx, options, sessions, getState }), "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }), - "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }), + "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit }), } } diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 8a7240c48..818fdb737 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -14,9 +14,9 @@ import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" export function createToolExecuteAfterHandler(input: { ctx: PluginInput pendingFilePaths: Map -}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { - const { ctx, pendingFilePaths } = input - + autoCommit: boolean + }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { + const { ctx, pendingFilePaths, autoCommit } = input return async (toolInput, toolOutput): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) if (!toolOutput) { @@ -76,7 +76,7 @@ export function createToolExecuteAfterHandler(input: { // Preserve original subagent response - critical for debugging failed tasks const originalResponse = toolOutput.output - toolOutput.output = ` +toolOutput.output = ` ## SUBAGENT WORK COMPLETED ${fileChanges} @@ -88,9 +88,8 @@ ${fileChanges} ${originalResponse} -${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} +${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit)} ` - log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { plan: boulderState.plan_name, progress: `${progress.completed}/${progress.total}`, diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 7302f8307..73436a019 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -8,6 +8,8 @@ export interface AtlasHookOptions { backgroundManager?: BackgroundManager isContinuationStopped?: (sessionID: string) => boolean agentOverrides?: AgentOverrides + /** Enable auto-commit after each atomic task completion (default: true) */ + autoCommit?: boolean } export interface ToolExecuteAfterInput { diff --git a/src/hooks/atlas/verification-reminders.ts b/src/hooks/atlas/verification-reminders.ts index f0c24c549..1955dde32 100644 --- a/src/hooks/atlas/verification-reminders.ts +++ b/src/hooks/atlas/verification-reminders.ts @@ -14,9 +14,22 @@ task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") export function buildOrchestratorReminder( planName: string, progress: { total: number; completed: number }, - sessionId: string + sessionId: string, + autoCommit: boolean = true ): string { const remaining = progress.total - progress.completed + + const commitStep = autoCommit + ? ` +**STEP 8: COMMIT ATOMIC UNIT** + +- Stage ONLY the verified changes +- Commit with clear message describing what was done +` + : "" + + const nextStepNumber = autoCommit ? 9 : 8 + return ` --- @@ -60,13 +73,8 @@ Update the plan file \`.sisyphus/plans/${planName}.md\`: - Use \`Edit\` tool to modify the checkbox **DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** - -**STEP 8: COMMIT ATOMIC UNIT** - -- Stage ONLY the verified changes -- Commit with clear message describing what was done - -**STEP 9: PROCEED TO NEXT TASK** +${commitStep} +**STEP ${nextStepNumber}: PROCEED TO NEXT TASK** - Read the plan file AGAIN to identify the next \`- [ ]\` task - Start immediately - DO NOT STOP diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index da453f58d..9cedce212 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -111,6 +111,7 @@ export function createContinuationHooks(args: { isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, agentOverrides: pluginConfig.agents, + autoCommit: pluginConfig.start_work?.auto_commit, })) : null From 5e726a2af2ff3f62999fe26bbb645c7148940858 Mon Sep 17 00:00:00 2001 From: acamq <179265037+acamq@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:45:35 -0700 Subject: [PATCH 36/62] fix(docs): remove corrupted text and duplicate entries in AGENTS.md - Remove accidental '7ZB|' keystroke insertion on line 7 - Remove duplicate schema tree entries (start-work.ts and internal/permission.ts) --- src/config/AGENTS.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 8838e91e4..0ec14879c 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -7ZB|22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional β€” omitted fields use plugin defaults. +22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional β€” omitted fields use plugin defaults. ## SCHEMA TREE @@ -33,8 +33,7 @@ config/schema/ β”œβ”€β”€ dynamic-context-pruning.ts # Context pruning settings β”œβ”€β”€ start-work.ts # StartWorkConfigSchema (auto_commit) └── internal/permission.ts # AgentPermissionSchema -β”œβ”€β”€ start-work.ts # StartWorkConfigSchema (auto_commit) -└── internal/permission.ts # AgentPermissionSchema + ``` ## ROOT SCHEMA FIELDS (28) From 866bd50dcace36ad5aec52a7b2149bdc9368f7e6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:38:27 +0000 Subject: [PATCH 37/62] @renanale has signed the CLA in code-yeongyu/oh-my-opencode#2201 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 0860b761e..45b1052a0 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1799,6 +1799,14 @@ "created_at": "2026-02-27T10:53:03Z", "repoId": 1108837393, "pullRequestNo": 2184 + }, + { + "name": "renanale", + "id": 37278838, + "comment_id": 3975562407, + "created_at": "2026-02-27T22:38:18Z", + "repoId": 1108837393, + "pullRequestNo": 2201 } ] } \ No newline at end of file From 7cec6f7c8b6a87dc6732b87d6f4094d0806fbc6a Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 00:23:33 +0100 Subject: [PATCH 38/62] fix(tools): resolve relative paths in glob/grep against project directory When models pass relative paths (e.g. 'apps/ios/CleanSlate') to glob/grep tools, they were passed directly to ripgrep which resolved them against process.cwd(). In OpenCode Desktop, process.cwd() is '/' causing all relative path lookups to fail with 'No such file or directory'. Fix: use path.resolve(ctx.directory, args.path) to resolve relative paths against the project directory instead of relying on process.cwd(). --- src/tools/glob/tools.ts | 7 +++++-- src/tools/grep/tools.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index 361d43cde..d808377b2 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRgFiles } from "./cli" @@ -22,10 +23,12 @@ export function createGlobTools(ctx: PluginInput): Record { + execute: async (args, context) => { try { const cli = await resolveGrepCliWithAutoInstall() - const searchPath = args.path ?? ctx.directory + const runtimeCtx = context as Record + const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory + const searchPath = args.path ? resolve(dir, args.path) : dir const paths = [searchPath] const result = await runRgFiles( diff --git a/src/tools/grep/tools.ts b/src/tools/grep/tools.ts index e4d0e0e42..b00c47540 100644 --- a/src/tools/grep/tools.ts +++ b/src/tools/grep/tools.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRg, runRgCount } from "./cli" @@ -32,10 +33,12 @@ export function createGrepTools(ctx: PluginInput): Record { + execute: async (args, context) => { try { const globs = args.include ? [args.include] : undefined - const searchPath = args.path ?? ctx.directory + const runtimeCtx = context as Record + const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory + const searchPath = args.path ? resolve(dir, args.path) : dir const paths = [searchPath] const outputMode = args.output_mode ?? "files_with_matches" const headLimit = args.head_limit ?? 0 From 43dfdb23803395c376b4d2ffdc3d8c0b98368b2c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 11:38:34 +0900 Subject: [PATCH 39/62] feat(hooks): add HTTP hook handler support Add type: "http" hook support matching Claude Code's HTTP hook specification. HTTP hooks send POST requests with JSON body, support env var interpolation in headers via allowedEnvVars, and configurable timeout. New files: - execute-http-hook.ts: HTTP hook execution with env var interpolation - dispatch-hook.ts: Unified dispatcher for command and HTTP hooks - execute-http-hook.test.ts: 14 tests covering all HTTP hook scenarios Modified files: - types.ts: Added HookHttp interface, HookAction union type - config.ts: Updated to accept HookAction in raw hook matchers - pre-tool-use/post-tool-use/stop/user-prompt-submit/pre-compact: Updated all 5 executors to dispatch HTTP hooks via dispatchHook() - plugin-loader/types.ts: Added "http" to HookEntry type union --- .../claude-code-plugin-loader/types.ts | 6 +- src/hooks/claude-code-hooks/config.ts | 4 +- src/hooks/claude-code-hooks/dispatch-hook.ts | 27 ++ .../execute-http-hook.test.ts | 237 ++++++++++++++++++ .../claude-code-hooks/execute-http-hook.ts | 87 +++++++ src/hooks/claude-code-hooks/post-tool-use.ts | 17 +- src/hooks/claude-code-hooks/pre-compact.ts | 17 +- src/hooks/claude-code-hooks/pre-tool-use.ts | 17 +- src/hooks/claude-code-hooks/stop.ts | 15 +- src/hooks/claude-code-hooks/types.ts | 12 +- .../claude-code-hooks/user-prompt-submit.ts | 15 +- 11 files changed, 397 insertions(+), 57 deletions(-) create mode 100644 src/hooks/claude-code-hooks/dispatch-hook.ts create mode 100644 src/hooks/claude-code-hooks/execute-http-hook.test.ts create mode 100644 src/hooks/claude-code-hooks/execute-http-hook.ts diff --git a/src/features/claude-code-plugin-loader/types.ts b/src/features/claude-code-plugin-loader/types.ts index 34e01937d..ff511fd57 100644 --- a/src/features/claude-code-plugin-loader/types.ts +++ b/src/features/claude-code-plugin-loader/types.ts @@ -81,10 +81,14 @@ export interface PluginManifest { * Hooks configuration */ export interface HookEntry { - type: "command" | "prompt" | "agent" + type: "command" | "prompt" | "agent" | "http" command?: string prompt?: string agent?: string + url?: string + headers?: Record + allowedEnvVars?: string[] + timeout?: number } export interface HookMatcher { diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index 3a03d200b..a2daf0039 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -1,12 +1,12 @@ import { join } from "path" import { existsSync } from "fs" import { getClaudeConfigDir } from "../../shared" -import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types" +import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types" interface RawHookMatcher { matcher?: string pattern?: string - hooks: HookCommand[] + hooks: HookAction[] } interface RawClaudeHooksConfig { diff --git a/src/hooks/claude-code-hooks/dispatch-hook.ts b/src/hooks/claude-code-hooks/dispatch-hook.ts new file mode 100644 index 000000000..5feeabb62 --- /dev/null +++ b/src/hooks/claude-code-hooks/dispatch-hook.ts @@ -0,0 +1,27 @@ +import type { HookAction } from "./types" +import type { CommandResult } from "../../shared/command-executor/execute-hook-command" +import { executeHookCommand } from "../../shared" +import { executeHttpHook } from "./execute-http-hook" +import { DEFAULT_CONFIG } from "./plugin-config" + +export function getHookIdentifier(hook: HookAction): string { + if (hook.type === "http") return hook.url + return hook.command.split("/").pop() || hook.command +} + +export async function dispatchHook( + hook: HookAction, + stdinJson: string, + cwd: string +): Promise { + if (hook.type === "http") { + return executeHttpHook(hook, stdinJson) + } + + return executeHookCommand( + hook.command, + stdinJson, + cwd, + { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } + ) +} diff --git a/src/hooks/claude-code-hooks/execute-http-hook.test.ts b/src/hooks/claude-code-hooks/execute-http-hook.test.ts new file mode 100644 index 000000000..a51b0b505 --- /dev/null +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" +import type { HookHttp } from "./types" + +const mockFetch = mock(() => + Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) +) + +const originalFetch = globalThis.fetch + +describe("executeHttpHook", () => { + beforeEach(() => { + globalThis.fetch = mockFetch as unknown as typeof fetch + mockFetch.mockReset() + mockFetch.mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) + ) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + describe("#given a basic HTTP hook", () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks/pre-tool-use", + } + const stdinData = JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash" }) + + it("#when executed #then sends POST request with correct body", async () => { + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, stdinData) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe("http://localhost:8080/hooks/pre-tool-use") + expect(options.method).toBe("POST") + expect(options.body).toBe(stdinData) + }) + + it("#when executed #then sets content-type to application/json", async () => { + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, stdinData) + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Content-Type"]).toBe("application/json") + }) + }) + + describe("#given an HTTP hook with headers and env var interpolation", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv, MY_TOKEN: "secret-123", OTHER_VAR: "other-value" } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("#when allowedEnvVars includes the var #then interpolates env var in headers", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + headers: { Authorization: "Bearer $MY_TOKEN" }, + allowedEnvVars: ["MY_TOKEN"], + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Authorization"]).toBe("Bearer secret-123") + }) + + it("#when env var uses ${VAR} syntax #then interpolates correctly", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + headers: { Authorization: "Bearer ${MY_TOKEN}" }, + allowedEnvVars: ["MY_TOKEN"], + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Authorization"]).toBe("Bearer secret-123") + }) + + it("#when env var not in allowedEnvVars #then replaces with empty string", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + headers: { Authorization: "Bearer $OTHER_VAR" }, + allowedEnvVars: ["MY_TOKEN"], + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Authorization"]).toBe("Bearer ") + }) + }) + + describe("#given an HTTP hook with timeout", () => { + it("#when timeout specified #then passes AbortSignal with timeout", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + timeout: 10, + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(options.signal).toBeDefined() + }) + }) + + describe("#given a successful HTTP response", () => { + it("#when response has JSON body #then returns parsed output", async () => { + mockFetch.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ decision: "allow", reason: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('"decision":"allow"') + }) + }) + + describe("#given a failing HTTP response", () => { + it("#when response status is 4xx #then returns exit code 1", async () => { + mockFetch.mockImplementation(() => + Promise.resolve(new Response("Bad Request", { status: 400 })) + ) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("400") + }) + + it("#when fetch throws network error #then returns exit code 1", async () => { + mockFetch.mockImplementation(() => Promise.reject(new Error("ECONNREFUSED"))) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("ECONNREFUSED") + }) + }) + + describe("#given response with exit code in JSON", () => { + it("#when JSON contains exitCode 2 #then uses that exit code", async () => { + mockFetch.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ exitCode: 2, stderr: "blocked" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(2) + }) + }) +}) + +describe("interpolateEnvVars", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv, TOKEN: "abc", SECRET: "xyz" } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("#given $VAR syntax #when var is allowed #then interpolates", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer $TOKEN", ["TOKEN"]) + + expect(result).toBe("Bearer abc") + }) + + it("#given ${VAR} syntax #when var is allowed #then interpolates", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN"]) + + expect(result).toBe("Bearer abc") + }) + + it("#given multiple vars #when some not allowed #then only interpolates allowed ones", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("$TOKEN:$SECRET", ["TOKEN"]) + + expect(result).toBe("abc:") + }) + + it("#given no allowedEnvVars #when called #then replaces all with empty", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer $TOKEN", []) + + expect(result).toBe("Bearer ") + }) +}) diff --git a/src/hooks/claude-code-hooks/execute-http-hook.ts b/src/hooks/claude-code-hooks/execute-http-hook.ts new file mode 100644 index 000000000..43d65bbab --- /dev/null +++ b/src/hooks/claude-code-hooks/execute-http-hook.ts @@ -0,0 +1,87 @@ +import type { HookHttp } from "./types" +import type { CommandResult } from "../../shared/command-executor/execute-hook-command" + +const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30 + +export function interpolateEnvVars( + value: string, + allowedEnvVars: string[] +): string { + const allowedSet = new Set(allowedEnvVars) + + let result = value.replace(/\$\{(\w+)\}/g, (_match, varName: string) => { + if (allowedSet.has(varName)) { + return process.env[varName] ?? "" + } + return "" + }) + + result = result.replace(/\$(\w+)/g, (_match, varName: string) => { + if (allowedSet.has(varName)) { + return process.env[varName] ?? "" + } + return "" + }) + + return result +} + +function resolveHeaders( + hook: HookHttp +): Record { + const headers: Record = { + "Content-Type": "application/json", + } + + if (!hook.headers) return headers + + const allowedEnvVars = hook.allowedEnvVars ?? [] + for (const [key, value] of Object.entries(hook.headers)) { + headers[key] = interpolateEnvVars(value, allowedEnvVars) + } + + return headers +} + +export async function executeHttpHook( + hook: HookHttp, + stdin: string +): Promise { + const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S + const headers = resolveHeaders(hook) + + try { + const response = await fetch(hook.url, { + method: "POST", + headers, + body: stdin, + signal: AbortSignal.timeout(timeoutS * 1000), + }) + + if (!response.ok) { + return { + exitCode: 1, + stderr: `HTTP hook returned status ${response.status}: ${response.statusText}`, + stdout: await response.text().catch(() => ""), + } + } + + const body = await response.text() + if (!body) { + return { exitCode: 0, stdout: "", stderr: "" } + } + + try { + const parsed = JSON.parse(body) as { exitCode?: number } + if (typeof parsed.exitCode === "number") { + return { exitCode: parsed.exitCode, stdout: body, stderr: "" } + } + } catch { + } + + return { exitCode: 0, stdout: body, stderr: "" } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { exitCode: 1, stderr: `HTTP hook error: ${message}` } + } +} diff --git a/src/hooks/claude-code-hooks/post-tool-use.ts b/src/hooks/claude-code-hooks/post-tool-use.ts index 31b88dc06..b119252c2 100644 --- a/src/hooks/claude-code-hooks/post-tool-use.ts +++ b/src/hooks/claude-code-hooks/post-tool-use.ts @@ -3,8 +3,8 @@ import type { PostToolUseOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -94,22 +94,17 @@ export async function executePostToolUseHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) { + if (hook.type === "command" && isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) { log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) continue } - const hookName = hook.command.split("/").pop() || hook.command + const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.stdout) { messages.push(result.stdout) diff --git a/src/hooks/claude-code-hooks/pre-compact.ts b/src/hooks/claude-code-hooks/pre-compact.ts index e2d877396..09d2425e0 100644 --- a/src/hooks/claude-code-hooks/pre-compact.ts +++ b/src/hooks/claude-code-hooks/pre-compact.ts @@ -3,8 +3,8 @@ import type { PreCompactOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" export interface PreCompactContext { @@ -50,22 +50,17 @@ export async function executePreCompactHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) { + if (hook.type === "command" && isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) { log("PreCompact hook command skipped (disabled by config)", { command: hook.command }) continue } - const hookName = hook.command.split("/").pop() || hook.command + const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.exitCode === 2) { log("PreCompact hook blocked", { hookName, stderr: result.stderr }) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index 2b5a33c5c..ec16369ec 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -4,8 +4,8 @@ import type { PermissionDecision, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" export interface PreToolUseContext { @@ -77,22 +77,17 @@ export async function executePreToolUseHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) { + if (hook.type === "command" && isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) { log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) continue } - const hookName = hook.command.split("/").pop() || hook.command + const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.exitCode === 2) { return { diff --git a/src/hooks/claude-code-hooks/stop.ts b/src/hooks/claude-code-hooks/stop.ts index 0073613b4..81cf821b9 100644 --- a/src/hooks/claude-code-hooks/stop.ts +++ b/src/hooks/claude-code-hooks/stop.ts @@ -3,8 +3,8 @@ import type { StopOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook } from "./dispatch-hook" import { getTodoPath } from "./todo" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -68,19 +68,14 @@ export async function executeStopHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) { + if (hook.type === "command" && isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) { log("Stop hook command skipped (disabled by config)", { command: hook.command }) continue } - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) // Check exit code first - exit code 2 means block if (result.exitCode === 2) { diff --git a/src/hooks/claude-code-hooks/types.ts b/src/hooks/claude-code-hooks/types.ts index 5d287f6ea..28924de10 100644 --- a/src/hooks/claude-code-hooks/types.ts +++ b/src/hooks/claude-code-hooks/types.ts @@ -12,7 +12,7 @@ export type ClaudeHookEvent = export interface HookMatcher { matcher: string - hooks: HookCommand[] + hooks: HookAction[] } export interface HookCommand { @@ -20,6 +20,16 @@ export interface HookCommand { command: string } +export interface HookHttp { + type: "http" + url: string + headers?: Record + allowedEnvVars?: string[] + timeout?: number +} + +export type HookAction = HookCommand | HookHttp + export interface ClaudeHooksConfig { PreToolUse?: HookMatcher[] PostToolUse?: HookMatcher[] diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts index 4fa732ae6..5f1cbc2e4 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -3,8 +3,8 @@ import type { PostToolUseOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" const USER_PROMPT_SUBMIT_TAG_OPEN = "" @@ -80,19 +80,14 @@ export async function executeUserPromptSubmitHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) { + if (hook.type === "command" && isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) { log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command }) continue } - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.stdout) { const output = result.stdout.trim() From 3eb53adfc3535c9bdee2f370e8dacf3a2a288989 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 12:00:02 +0900 Subject: [PATCH 40/62] fix(hooks): resolve cubic review issues - Replace two-pass env interpolation with single-pass combined regex to prevent re-interpolation of $-sequences in substituted header values - Convert HookEntry to discriminated union so type: "http" requires url, preventing invalid configs from passing type checking - Add regression test for double-interpolation edge case --- src/features/claude-code-plugin-loader/types.ts | 15 +++++---------- .../claude-code-hooks/execute-http-hook.test.ts | 9 +++++++++ src/hooks/claude-code-hooks/execute-http-hook.ts | 13 ++----------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/features/claude-code-plugin-loader/types.ts b/src/features/claude-code-plugin-loader/types.ts index ff511fd57..f384f4ef6 100644 --- a/src/features/claude-code-plugin-loader/types.ts +++ b/src/features/claude-code-plugin-loader/types.ts @@ -80,16 +80,11 @@ export interface PluginManifest { /** * Hooks configuration */ -export interface HookEntry { - type: "command" | "prompt" | "agent" | "http" - command?: string - prompt?: string - agent?: string - url?: string - headers?: Record - allowedEnvVars?: string[] - timeout?: number -} +export type HookEntry = + | { type: "command"; command?: string } + | { type: "prompt"; prompt?: string } + | { type: "agent"; agent?: string } + | { type: "http"; url: string; headers?: Record; allowedEnvVars?: string[]; timeout?: number } export interface HookMatcher { matcher?: string diff --git a/src/hooks/claude-code-hooks/execute-http-hook.test.ts b/src/hooks/claude-code-hooks/execute-http-hook.test.ts index a51b0b505..bc7e1f598 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -227,6 +227,15 @@ describe("interpolateEnvVars", () => { expect(result).toBe("abc:") }) + it("#given ${VAR} where value contains $ANOTHER #when both allowed #then does not double-interpolate", async () => { + process.env = { ...process.env, TOKEN: "val$SECRET", SECRET: "oops" } + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN", "SECRET"]) + + expect(result).toBe("Bearer val$SECRET") + }) + it("#given no allowedEnvVars #when called #then replaces all with empty", async () => { const { interpolateEnvVars } = await import("./execute-http-hook") diff --git a/src/hooks/claude-code-hooks/execute-http-hook.ts b/src/hooks/claude-code-hooks/execute-http-hook.ts index 43d65bbab..bd985dbc0 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.ts @@ -9,23 +9,14 @@ export function interpolateEnvVars( ): string { const allowedSet = new Set(allowedEnvVars) - let result = value.replace(/\$\{(\w+)\}/g, (_match, varName: string) => { + return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (_match, bracedVar: string | undefined, bareVar: string | undefined) => { + const varName = (bracedVar ?? bareVar) as string if (allowedSet.has(varName)) { return process.env[varName] ?? "" } return "" }) - - result = result.replace(/\$(\w+)/g, (_match, varName: string) => { - if (allowedSet.has(varName)) { - return process.env[varName] ?? "" - } - return "" - }) - - return result } - function resolveHeaders( hook: HookHttp ): Record { From 4740515f2f8e52d40f400ad2dc39b0fb92a912c1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 12:04:18 +0900 Subject: [PATCH 41/62] fix(agents): replace active polling with notification-based waiting for background tasks Sisyphus prompt instructed 'your next action is background_output' which caused agents to repeatedly poll running tasks instead of ending their response and waiting for the system notification. - Replace 'STOP all other output' with 'end your response' (actionable) - Add system-reminder notification mechanism explanation - Add explicit 'Do NOT poll' prohibition - Reduce background_cancel(all=true) mentions from 5x to 1x (Hard Blocks) - Reduce Oracle collect obligation from 4x to 2x - Remove motivational fluff ('blind spots', 'normal and expected') Net: -2 lines, clearer mechanism, eliminates polling loop root cause. --- src/agents/dynamic-agent-prompt-builder.ts | 17 ++++++++--------- src/agents/sisyphus.ts | 15 +++++++-------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index 5685431b2..79a6a17f5 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -277,12 +277,11 @@ Briefly announce "Consulting Oracle for [reason]" before invocation. ### Oracle Background Task Policy: -**You MUST collect Oracle results before your final answer. No exceptions.** +**Collect Oracle results before your final answer. No exceptions.** -- Oracle may take several minutes. This is normal and expected. -- When Oracle is running and you finish your own exploration/analysis, your next action is \`background_output(task_id="...")\` on Oracle β€” NOT delivering a final answer. -- Oracle catches blind spots you cannot see β€” its value is HIGHEST when you think you don't need it. -- **NEVER** cancel Oracle. **NEVER** use \`background_cancel(all=true)\` when Oracle is running. Cancel disposable tasks (explore, librarian) individually by taskId instead. +- Oracle takes minutes. When done with your own work: **end your response** β€” wait for the \`\`. +- Do NOT poll \`background_output\` on a running Oracle. The notification will come. +- Never cancel Oracle. ` } @@ -292,8 +291,8 @@ export function buildHardBlocksSection(): string { "- Commit without explicit request β€” **Never**", "- Speculate about unread code β€” **Never**", "- Leave code in broken state after failures β€” **Never**", - "- `background_cancel(all=true)` when Oracle is running β€” **Never.** Cancel tasks individually by taskId.", - "- Delivering final answer before collecting Oracle result β€” **Never.** Always `background_output` Oracle first.", + "- `background_cancel(all=true)` β€” **Never.** Always cancel individually by taskId.", + "- Delivering final answer before collecting Oracle result β€” **Never.**", ] return `## Hard Blocks (NEVER violate) @@ -308,8 +307,8 @@ export function buildAntiPatternsSection(): string { "- **Testing**: Deleting failing tests to \"pass\"", "- **Search**: Firing agents for single-line typos or obvious syntax errors", "- **Debugging**: Shotgun debugging, random changes", - "- **Background Tasks**: `background_cancel(all=true)` β€” always cancel individually by taskId", - "- **Oracle**: Skipping Oracle results when Oracle was launched β€” ALWAYS collect via `background_output`", + "- **Background Tasks**: Polling `background_output` on running tasks β€” end response and wait for notification", + "- **Oracle**: Delivering answer without collecting Oracle results", ] return `## Anti-Patterns (BLOCKING violations) diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 06debf111..950df6b1c 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -329,7 +329,7 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio // Reference Grep (external) task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials β€” production security guidance only.") task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials β€” I need battle-tested patterns with proper error handling.") -// Continue working immediately. Collect with background_output when needed. +// Continue working immediately. System notifies on completion β€” collect with background_output then. // WRONG: Sequential or blocking result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian @@ -337,10 +337,10 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp ### Background Result Collection: 1. Launch parallel agents \u2192 receive task_ids -2. Continue immediate work (explore, librarian results) -3. When results needed: \`background_output(task_id="...")\` -4. **If Oracle is running**: STOP all other output. Follow Oracle Completion Protocol in . -5. Cleanup: Cancel disposable tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. Never use \`background_cancel(all=true)\`. +2. Continue immediate work +3. System sends \`\` on each task completion β€” then call \`background_output(task_id="...")\` +4. Need results not yet ready? **End your response.** The notification will trigger your next turn. +5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\` ### Search Stop Conditions @@ -477,9 +477,8 @@ If verification fails: 3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes." ### Before Delivering Final Answer: -- **If Oracle is running**: STOP. Follow Oracle Completion Protocol in . Do NOT deliver any answer. -- Cancel disposable background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. -- **Never use \`background_cancel(all=true)\`.** +- If Oracle is running: **end your response** and wait for the completion notification first. +- Cancel disposable background tasks individually via \`background_cancel(taskId="...")\`. ${oracleSection} From 4dae458cf7d4f3d9841bcecb4e715cbee5fd1d0c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 12:05:08 +0900 Subject: [PATCH 42/62] style(hooks): add blank line between interpolateEnvVars and resolveHeaders --- src/hooks/claude-code-hooks/execute-http-hook.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/claude-code-hooks/execute-http-hook.ts b/src/hooks/claude-code-hooks/execute-http-hook.ts index bd985dbc0..3ad2c5e57 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.ts @@ -17,6 +17,7 @@ export function interpolateEnvVars( return "" }) } + function resolveHeaders( hook: HookHttp ): Record { From 13d689cb3acd495f5c57b6b1b11a16f2b87fd3c7 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 12:13:10 +0900 Subject: [PATCH 43/62] feat(agents): add Plan Agent dependency and strengthen Deep Parallel Delegation for non-Claude models Non-Claude models skip planning and under-parallelize. Two new sections injected only when model is not Claude: - Plan Agent Dependency: multi-step tasks MUST consult Plan Agent first, use session_id for follow-ups, ask aggressively when ambiguous - Deep Parallel Delegation (rewrite): explicit '4 units = 4 agents' pattern, each with clear GOAL + success criteria, all run_in_background --- .../dynamic-agent-prompt-builder.test.ts | 84 +++++++++++++++++++ src/agents/dynamic-agent-prompt-builder.ts | 27 ++++-- src/agents/sisyphus.ts | 23 ++++- 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/agents/dynamic-agent-prompt-builder.test.ts b/src/agents/dynamic-agent-prompt-builder.test.ts index f105542b7..8572e72eb 100644 --- a/src/agents/dynamic-agent-prompt-builder.test.ts +++ b/src/agents/dynamic-agent-prompt-builder.test.ts @@ -4,6 +4,8 @@ import { describe, it, expect } from "bun:test" import { buildCategorySkillsDelegationGuide, buildUltraworkSection, + buildDeepParallelSection, + buildNonClaudePlannerSection, type AvailableSkill, type AvailableCategory, type AvailableAgent, @@ -172,4 +174,86 @@ describe("buildUltraworkSection", () => { }) }) +describe("buildDeepParallelSection", () => { + const deepCategory: AvailableCategory = { name: "deep", description: "Autonomous problem-solving" } + const otherCategory: AvailableCategory = { name: "quick", description: "Trivial tasks" } + + it("#given non-Claude model with deep category #when building #then returns parallel delegation section", () => { + //#given + const model = "google/gemini-3-pro" + const categories = [deepCategory, otherCategory] + + //#when + const result = buildDeepParallelSection(model, categories) + + //#then + expect(result).toContain("Deep Parallel Delegation") + expect(result).toContain("EVERY independent unit") + expect(result).toContain("run_in_background=true") + expect(result).toContain("4 independent units") + }) + + it("#given Claude model #when building #then returns empty", () => { + //#given + const model = "anthropic/claude-opus-4-6" + const categories = [deepCategory] + + //#when + const result = buildDeepParallelSection(model, categories) + + //#then + expect(result).toBe("") + }) + + it("#given non-Claude model without deep category #when building #then returns empty", () => { + //#given + const model = "openai/gpt-5.2" + const categories = [otherCategory] + + //#when + const result = buildDeepParallelSection(model, categories) + + //#then + expect(result).toBe("") + }) +}) + +describe("buildNonClaudePlannerSection", () => { + it("#given non-Claude model #when building #then returns plan agent section", () => { + //#given + const model = "google/gemini-3-pro" + + //#when + const result = buildNonClaudePlannerSection(model) + + //#then + expect(result).toContain("Plan Agent") + expect(result).toContain("session_id") + expect(result).toContain("Multi-step") + }) + + it("#given Claude model #when building #then returns empty", () => { + //#given + const model = "anthropic/claude-sonnet-4-6" + + //#when + const result = buildNonClaudePlannerSection(model) + + //#then + expect(result).toBe("") + }) + + it("#given GPT model #when building #then returns plan agent section", () => { + //#given + const model = "openai/gpt-5.2" + + //#when + const result = buildNonClaudePlannerSection(model) + + //#then + expect(result).toContain("Plan Agent") + expect(result).not.toBe("") + }) +}) + diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index 79a6a17f5..f6e8cbc65 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -316,6 +316,22 @@ export function buildAntiPatternsSection(): string { ${patterns.join("\n")}` } +export function buildNonClaudePlannerSection(model: string): string { + const isNonClaude = !model.toLowerCase().includes('claude') + if (!isNonClaude) return "" + + return `### Plan Agent Dependency (Non-Claude) + +Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan. + +- Single-file fix or trivial change β†’ proceed directly +- Anything else (2+ steps, unclear scope, architecture) β†’ \`task(subagent_type="plan", ...)\` FIRST +- Use \`session_id\` to resume the same Plan Agent β€” ask follow-up questions aggressively +- If ANY part of the task is ambiguous, ask Plan Agent before guessing + +Plan Agent returns a structured work breakdown with parallel execution opportunities. Follow it.` +} + export function buildDeepParallelSection(model: string, categories: AvailableCategory[]): string { const isNonClaude = !model.toLowerCase().includes('claude') const hasDeepCategory = categories.some(c => c.name === 'deep') @@ -324,12 +340,13 @@ export function buildDeepParallelSection(model: string, categories: AvailableCat return `### Deep Parallel Delegation -For implementation tasks, actively decompose and delegate to \`deep\` category agents in parallel. +Delegate EVERY independent unit to a \`deep\` agent in parallel (\`run_in_background=true\`). +If a task decomposes into 4 independent units, spawn 4 agents simultaneously β€” not 1 at a time. -1. Break the implementation into independent work units -2. Maximize parallel deep agents β€” spawn one per independent unit (\`run_in_background=true\`) -3. Give each agent a GOAL, not step-by-step instructions β€” deep agents explore and solve autonomously -4. Collect results, integrate, verify coherence` +1. Decompose the implementation into independent work units +2. Assign one \`deep\` agent per unit β€” all via \`run_in_background=true\` +3. Give each agent a clear GOAL with success criteria, not step-by-step instructions +4. Collect all results, integrate, verify coherence across units` } export function buildUltraworkSection( diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 950df6b1c..042cec1a1 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -6,6 +6,8 @@ import { buildGeminiDelegationOverride, buildGeminiVerificationOverride, buildGeminiIntentGateEnforcement, + buildGeminiToolGuide, + buildGeminiToolCallExamples, } from "./sisyphus-gemini-overlays"; const MODE: AgentMode = "all"; @@ -32,6 +34,7 @@ import { buildHardBlocksSection, buildAntiPatternsSection, buildDeepParallelSection, + buildNonClaudePlannerSection, categorizeTools, } from "./dynamic-agent-prompt-builder"; @@ -170,6 +173,7 @@ function buildDynamicSisyphusPrompt( const hardBlocks = buildHardBlocksSection(); const antiPatterns = buildAntiPatternsSection(); const deepParallelSection = buildDeepParallelSection(model, availableCategories); + const nonClaudePlannerSection = buildNonClaudePlannerSection(model); const taskManagementSection = buildTaskManagementSection(useTaskSystem); const todoHookNote = useTaskSystem ? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])" @@ -364,6 +368,8 @@ STOP searching when: ${categorySkillsGuide} +${nonClaudePlannerSection} + ${deepParallelSection} ${delegationTable} @@ -564,12 +570,25 @@ export function createSisyphusAgent( : buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem); if (isGeminiModel(model)) { + // 1. Intent gate + tool mandate β€” early in prompt (after intent verbalization) prompt = prompt.replace( "", `\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}` ); - prompt += "\n" + buildGeminiDelegationOverride(); - prompt += "\n" + buildGeminiVerificationOverride(); + + // 2. Tool guide + examples β€” after tool_usage_rules (where tools are discussed) + prompt = prompt.replace( + "", + `\n\n${buildGeminiToolGuide()}\n\n${buildGeminiToolCallExamples()}` + ); + + // 3. Delegation + verification overrides β€” before Constraints (NOT at prompt end) + // Gemini suffers from lost-in-the-middle: content at prompt end gets weaker attention. + // Placing these before ensures they're in a high-attention zone. + prompt = prompt.replace( + "", + `${buildGeminiDelegationOverride()}\n\n${buildGeminiVerificationOverride()}\n\n` + ); } const permission = { From 74f799244256981faeb84a48595bd3df30fc734a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 12:21:16 +0900 Subject: [PATCH 44/62] feat(agents): add Gemini tool guide and few-shot examples to system prompt Embed tool usage guide (per-tool parallel/sequential signals) and 5 concrete tool-calling examples directly in Gemini system prompt. Modeled after Antigravity's inline schema approach to improve Gemini tool-call quality. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/agents/sisyphus-gemini-overlays.ts | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/agents/sisyphus-gemini-overlays.ts b/src/agents/sisyphus-gemini-overlays.ts index e1e239332..6860e3eaa 100644 --- a/src/agents/sisyphus-gemini-overlays.ts +++ b/src/agents/sisyphus-gemini-overlays.ts @@ -39,6 +39,136 @@ Then ACTUALLY CALL those tools using the JSON tool schema. Produce the tool_use `; } +export function buildGeminiToolGuide(): string { + return ` +## Tool Usage Guide β€” WHEN and HOW to Call Each Tool + +You have access to tools via function calling. This guide defines WHEN to call each one. +**Violating these patterns = failed response.** + +### Reading & Search (ALWAYS parallelizable β€” call multiple simultaneously) + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`Read\` | Before making ANY claim about file contents. Before editing any file. | οΏ½ Yes β€” read multiple files at once | +| \`Grep\` | Finding patterns, imports, usages across codebase. BEFORE claiming "X is used in Y". | βœ… Yes β€” run multiple greps at once | +| \`Glob\` | Finding files by name/extension pattern. BEFORE claiming "file X exists". | βœ… Yes β€” run multiple globs at once | +| \`AstGrepSearch\` | Finding code patterns with AST awareness (structural matches). | βœ… Yes | + +### Code Intelligence (parallelizable on different files) + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`LspDiagnostics\` | **AFTER EVERY edit.** BEFORE claiming task is done. MANDATORY. | βœ… Yes β€” different files | +| \`LspGotoDefinition\` | Finding where a symbol is defined. | βœ… Yes | +| \`LspFindReferences\` | Finding all usages of a symbol across workspace. | βœ… Yes | +| \`LspSymbols\` | Getting file outline or searching workspace symbols. | βœ… Yes | + +### Editing (SEQUENTIAL β€” must Read first) + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`Edit\` | Modifying existing files. MUST Read file first to get LINE#ID anchors. | ❌ After Read | +| \`Write\` | Creating NEW files only. Or full file overwrite. | ❌ Sequential | + +### Execution & Delegation + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`Bash\` | Running tests, builds, git commands. | ❌ Usually sequential | +| \`Task\` | ANY non-trivial implementation. Research via explore/librarian. | βœ… Fire multiple in background | + +### Correct Sequences (MANDATORY β€” follow these exactly): + +1. **Answer about code**: Read β†’ (analyze) β†’ Answer +2. **Edit code**: Read β†’ Edit β†’ LspDiagnostics β†’ Report +3. **Find something**: Grep/Glob (parallel) β†’ Read results β†’ Report +4. **Implement feature**: Task(delegate) β†’ Verify results β†’ Report +5. **Debug**: Read error β†’ Read file β†’ Grep related β†’ Fix β†’ LspDiagnostics + +### PARALLEL RULES: + +- **Independent reads/searches**: ALWAYS call simultaneously in ONE response +- **Dependent operations**: Call sequentially (Edit AFTER Read, LspDiagnostics AFTER Edit) +- **Background agents**: ALWAYS \`run_in_background=true\`, continue working +`; +} + +export function buildGeminiToolCallExamples(): string { + return ` +## Correct Tool Calling Patterns β€” Follow These Examples + +### Example 1: User asks about code β†’ Read FIRST, then answer +**User**: "How does the auth middleware work?" +**CORRECT**: +\`\`\` +β†’ Call Read(filePath="/src/middleware/auth.ts") +β†’ Call Read(filePath="/src/config/auth.ts") // parallel with above +β†’ (After reading) Answer based on ACTUAL file contents +\`\`\` +**WRONG**: +\`\`\` +β†’ "The auth middleware likely validates JWT tokens by..." ← HALLUCINATION. You didn't read the file. +\`\`\` + +### Example 2: User asks to edit code β†’ Read, Edit, Verify +**User**: "Fix the type error in user.ts" +**CORRECT**: +\`\`\` +β†’ Call Read(filePath="/src/models/user.ts") +β†’ Call LspDiagnostics(filePath="/src/models/user.ts") // parallel with Read +β†’ (After reading) Call Edit with LINE#ID anchors +β†’ Call LspDiagnostics(filePath="/src/models/user.ts") // verify fix +β†’ Report: "Fixed. Diagnostics clean." +\`\`\` +**WRONG**: +\`\`\` +β†’ Call Edit without reading first ← No LINE#ID anchors = WILL FAIL +β†’ Skip LspDiagnostics after edit ← UNVERIFIED +\`\`\` + +### Example 3: User asks to find something β†’ Search in parallel +**User**: "Where is the database connection configured?" +**CORRECT**: +\`\`\` +β†’ Call Grep(pattern="database|connection|pool", path="/src") // fires simultaneously +β†’ Call Glob(pattern="**/*database*") // fires simultaneously +β†’ Call Glob(pattern="**/*db*") // fires simultaneously +β†’ (After results) Read the most relevant files +β†’ Report findings with file paths +\`\`\` + +### Example 4: User asks to implement a feature β†’ DELEGATE +**User**: "Add a new /health endpoint to the API" +**CORRECT**: +\`\`\` +β†’ Call Task(category="quick", load_skills=["typescript-programmer"], prompt="...") +β†’ (After agent completes) Read changed files to verify +β†’ Call LspDiagnostics on changed files +β†’ Report +\`\`\` +**WRONG**: +\`\`\` +β†’ Write the code yourself ← YOU ARE AN ORCHESTRATOR, NOT AN IMPLEMENTER +\`\`\` + +### Example 5: Investigation β‰  Implementation +**User**: "Look into why the tests are failing" +**CORRECT**: +\`\`\` +β†’ Call Bash(command="npm test") // see actual failures +β†’ Call Read on failing test files +β†’ Call Read on source files under test +β†’ Report: "Tests fail because X. Root cause: Y. Proposed fix: Z." +β†’ STOP β€” wait for user to say "fix it" +\`\`\` +**WRONG**: +\`\`\` +β†’ Start editing source files immediately ← "look into" β‰  "fix" +\`\`\` +`; +} + export function buildGeminiDelegationOverride(): string { return ` ## DELEGATION IS MANDATORY β€” YOU ARE NOT AN IMPLEMENTER From cc6ab1addcaa26f179c3e6de6e8bb5a270b1d6a4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 13:16:43 +0900 Subject: [PATCH 45/62] feat(hooks): add read-image-resizer hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercepts Read tool output with image attachments and resizes to comply with Anthropic API limits (≀1568px long edge, ≀5MB). Only activates for Anthropic provider sessions and appends resize metadata (original/new resolution, token count) to tool output. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/config/schema/hooks.ts | 1 + src/hooks/index.ts | 1 + src/hooks/read-image-resizer/hook.test.ts | 286 ++++++++++++++++++ src/hooks/read-image-resizer/hook.ts | 197 ++++++++++++ .../image-dimensions.test.ts | 108 +++++++ .../read-image-resizer/image-dimensions.ts | 187 ++++++++++++ .../read-image-resizer/image-resizer.test.ts | 132 ++++++++ src/hooks/read-image-resizer/image-resizer.ts | 184 +++++++++++ src/hooks/read-image-resizer/index.ts | 1 + src/hooks/read-image-resizer/types.ts | 16 + src/plugin/hooks/create-tool-guard-hooks.ts | 7 + src/plugin/tool-execute-after.ts | 1 + 12 files changed, 1121 insertions(+) create mode 100644 src/hooks/read-image-resizer/hook.test.ts create mode 100644 src/hooks/read-image-resizer/hook.ts create mode 100644 src/hooks/read-image-resizer/image-dimensions.test.ts create mode 100644 src/hooks/read-image-resizer/image-dimensions.ts create mode 100644 src/hooks/read-image-resizer/image-resizer.test.ts create mode 100644 src/hooks/read-image-resizer/image-resizer.ts create mode 100644 src/hooks/read-image-resizer/index.ts create mode 100644 src/hooks/read-image-resizer/types.ts diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 8a7ecfdfb..28ab58851 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([ "write-existing-file-guard", "anthropic-effort", "hashline-read-enhancer", + "read-image-resizer", ]) export type HookName = z.infer diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f992b0d7d..171f5dd12 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -50,3 +50,4 @@ export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallba export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery"; +export { createReadImageResizerHook } from "./read-image-resizer" diff --git a/src/hooks/read-image-resizer/hook.test.ts b/src/hooks/read-image-resizer/hook.test.ts new file mode 100644 index 000000000..0b55b885d --- /dev/null +++ b/src/hooks/read-image-resizer/hook.test.ts @@ -0,0 +1,286 @@ +/// + +import { beforeEach, describe, expect, it, mock } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" + +import type { ImageDimensions, ResizeResult } from "./types" + +const mockParseImageDimensions = mock((): ImageDimensions | null => null) +const mockCalculateTargetDimensions = mock((): ImageDimensions | null => null) +const mockResizeImage = mock(async (): Promise => null) +const mockGetSessionModel = mock((_sessionID: string) => ({ + providerID: "anthropic", + modelID: "claude-sonnet-4-6", +} as { providerID: string; modelID: string } | undefined)) + +mock.module("./image-dimensions", () => ({ + parseImageDimensions: mockParseImageDimensions, +})) + +mock.module("./image-resizer", () => ({ + calculateTargetDimensions: mockCalculateTargetDimensions, + resizeImage: mockResizeImage, +})) + +mock.module("../../shared/session-model-state", () => ({ + getSessionModel: mockGetSessionModel, +})) + +import { createReadImageResizerHook } from "./hook" + +type ToolOutput = { + title: string + output: string + metadata: unknown + attachments?: Array<{ mime: string; url: string; filename?: string }> +} + +function createMockContext(): PluginInput { + return { + client: {} as PluginInput["client"], + directory: "/test", + } as PluginInput +} + +function createInput(tool: string): { tool: string; sessionID: string; callID: string } { + return { + tool, + sessionID: "session-1", + callID: "call-1", + } +} + +describe("createReadImageResizerHook", () => { + beforeEach(() => { + mockParseImageDimensions.mockReset() + mockCalculateTargetDimensions.mockReset() + mockResizeImage.mockReset() + mockGetSessionModel.mockReset() + mockGetSessionModel.mockReturnValue({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }) + }) + + it("skips non-Read tools", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Bash"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips when provider is not anthropic", async () => { + //#given + mockGetSessionModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5.3-codex" }) + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips when session model is unknown", async () => { + //#given + mockGetSessionModel.mockReturnValue(undefined) + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips Read output with no attachments", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips non-image attachments", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "application/pdf", url: "data:application/pdf;base64,AAAA", filename: "file.pdf" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips unsupported image mime types", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/heic", url: "data:image/heic;base64,AAAA", filename: "photo.heic" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("appends within-limits metadata when image is already valid", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 800, height: 600 }) + mockCalculateTargetDimensions.mockReturnValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toContain("[Image Info]") + expect(output.output).toContain("within limits") + expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old") + expect(mockResizeImage).not.toHaveBeenCalled() + }) + + it("replaces attachment URL and appends resize metadata for oversized image", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) + mockResizeImage.mockResolvedValue({ + resizedDataUrl: "data:image/png;base64,resized", + original: { width: 3000, height: 2000 }, + resized: { width: 1568, height: 1045 }, + }) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "big.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,resized") + expect(output.output).toContain("[Image Resize Info]") + expect(output.output).toContain("resized") + }) + + it("keeps original attachment URL and marks resize skipped when resize fails", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) + mockResizeImage.mockResolvedValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "fail.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old") + expect(output.output).toContain("resize skipped") + }) + + it("appends unknown-dimensions metadata when parsing fails", async () => { + //#given + mockParseImageDimensions.mockReturnValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "corrupt.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toContain("dimensions could not be parsed") + expect(mockCalculateTargetDimensions).not.toHaveBeenCalled() + }) + + it("fires for lowercase read tool name", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 800, height: 600 }) + mockCalculateTargetDimensions.mockReturnValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("read"), output) + + //#then + expect(mockParseImageDimensions).toHaveBeenCalledTimes(1) + expect(output.output).toContain("within limits") + }) +}) diff --git a/src/hooks/read-image-resizer/hook.ts b/src/hooks/read-image-resizer/hook.ts new file mode 100644 index 000000000..e5a199ae8 --- /dev/null +++ b/src/hooks/read-image-resizer/hook.ts @@ -0,0 +1,197 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { ImageAttachment, ImageDimensions } from "./types" +import { parseImageDimensions } from "./image-dimensions" +import { calculateTargetDimensions, resizeImage } from "./image-resizer" +import { log } from "../../shared" +import { getSessionModel } from "../../shared/session-model-state" +const SUPPORTED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) +const TOKEN_DIVISOR = 750 +interface ResizeEntry { + filename: string + originalDims: ImageDimensions | null + resizedDims: ImageDimensions | null + status: "resized" | "within-limits" | "resize-skipped" | "unknown-dims" +} +function isReadTool(toolName: string): boolean { + return toolName.toLowerCase() === "read" +} +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null + } + return value as Record +} +function isImageAttachmentRecord( + value: Record, +): value is Record & ImageAttachment { + const filename = value.filename + return ( + typeof value.mime === "string" && + typeof value.url === "string" && + (typeof filename === "undefined" || typeof filename === "string") + ) +} +function extractImageAttachments(output: Record): ImageAttachment[] { + const attachmentsValue = output.attachments + if (!Array.isArray(attachmentsValue)) { + return [] + } + const attachments: ImageAttachment[] = [] + for (const attachmentValue of attachmentsValue) { + const attachmentRecord = asRecord(attachmentValue) + if (!attachmentRecord) { + continue + } + + const mime = attachmentRecord.mime + const url = attachmentRecord.url + if (typeof mime !== "string" || typeof url !== "string") { + continue + } + + const normalizedMime = mime.toLowerCase() + if (!SUPPORTED_IMAGE_MIMES.has(normalizedMime)) { + continue + } + + attachmentRecord.mime = normalizedMime + attachmentRecord.url = url + if (isImageAttachmentRecord(attachmentRecord)) { + attachments.push(attachmentRecord) + } + } + + return attachments +} +function calculateTokens(width: number, height: number): number { + return Math.ceil((width * height) / TOKEN_DIVISOR) +} +function formatResizeAppendix(entries: ResizeEntry[]): string { + const header = entries.some((entry) => entry.status === "resized") ? "[Image Resize Info]" : "[Image Info]" + const lines = [`\n\n${header}`] + + for (const entry of entries) { + if (entry.status === "unknown-dims" || !entry.originalDims) { + lines.push(`- ${entry.filename}: dimensions could not be parsed`) + continue + } + + const original = entry.originalDims + const originalText = `${original.width}x${original.height}` + const originalTokens = calculateTokens(original.width, original.height) + + if (entry.status === "within-limits") { + lines.push(`- ${entry.filename}: ${originalText} (within limits, tokens: ${originalTokens})`) + continue + } + + if (entry.status === "resize-skipped") { + lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`) + continue + } + + if (!entry.resizedDims) { + lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`) + continue + } + + const resized = entry.resizedDims + const resizedText = `${resized.width}x${resized.height}` + const resizedTokens = calculateTokens(resized.width, resized.height) + lines.push( + `- ${entry.filename}: ${originalText} -> ${resizedText} (resized, tokens: ${originalTokens} -> ${resizedTokens})`, + ) + } + + return lines.join("\n") +} +function resolveFilename(attachment: ImageAttachment, index: number): string { + if (attachment.filename && attachment.filename.trim().length > 0) { + return attachment.filename + } + + return `image-${index + 1}` +} +export function createReadImageResizerHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown }, + ) => { + if (!isReadTool(input.tool)) { + return + } + + const sessionModel = getSessionModel(input.sessionID) + if (sessionModel?.providerID !== "anthropic") { + return + } + + if (typeof output.output !== "string") { + return + } + + const outputRecord = output as Record + const attachments = extractImageAttachments(outputRecord) + if (attachments.length === 0) { + return + } + + const entries: ResizeEntry[] = [] + for (const [index, attachment] of attachments.entries()) { + const filename = resolveFilename(attachment, index) + + try { + const originalDims = parseImageDimensions(attachment.url, attachment.mime) + if (!originalDims) { + entries.push({ filename, originalDims: null, resizedDims: null, status: "unknown-dims" }) + continue + } + + const targetDims = calculateTargetDimensions(originalDims.width, originalDims.height) + if (!targetDims) { + entries.push({ + filename, + originalDims, + resizedDims: null, + status: "within-limits", + }) + continue + } + + const resizedResult = await resizeImage(attachment.url, attachment.mime, targetDims) + if (!resizedResult) { + entries.push({ + filename, + originalDims, + resizedDims: null, + status: "resize-skipped", + }) + continue + } + + attachment.url = resizedResult.resizedDataUrl + + entries.push({ + filename, + originalDims: resizedResult.original, + resizedDims: resizedResult.resized, + status: "resized", + }) + } catch (error) { + log("[read-image-resizer] attachment processing failed", { + error: error instanceof Error ? error.message : String(error), + filename, + }) + entries.push({ filename, originalDims: null, resizedDims: null, status: "unknown-dims" }) + } + } + + if (entries.length === 0) { + return + } + + output.output += formatResizeAppendix(entries) + }, + } +} diff --git a/src/hooks/read-image-resizer/image-dimensions.test.ts b/src/hooks/read-image-resizer/image-dimensions.test.ts new file mode 100644 index 000000000..47beb2714 --- /dev/null +++ b/src/hooks/read-image-resizer/image-dimensions.test.ts @@ -0,0 +1,108 @@ +/// + +import { describe, expect, it } from "bun:test" + +import { parseImageDimensions } from "./image-dimensions" + +const PNG_1X1_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + +const GIF_1X1_DATA_URL = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + +function createPngDataUrl(width: number, height: number): string { + const buf = Buffer.alloc(33) + buf.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0) + buf.writeUInt32BE(13, 8) + buf.set([0x49, 0x48, 0x44, 0x52], 12) + buf.writeUInt32BE(width, 16) + buf.writeUInt32BE(height, 20) + return `data:image/png;base64,${buf.toString("base64")}` +} + +function createGifDataUrl(width: number, height: number): string { + const buf = Buffer.alloc(10) + buf.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 0) + buf.writeUInt16LE(width, 6) + buf.writeUInt16LE(height, 8) + return `data:image/gif;base64,${buf.toString("base64")}` +} + +describe("parseImageDimensions", () => { + it("parses PNG 1x1 dimensions", () => { + //#given + const dataUrl = PNG_1X1_DATA_URL + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toEqual({ width: 1, height: 1 }) + }) + + it("parses PNG dimensions from IHDR", () => { + //#given + const dataUrl = createPngDataUrl(3000, 2000) + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toEqual({ width: 3000, height: 2000 }) + }) + + it("parses GIF 1x1 dimensions", () => { + //#given + const dataUrl = GIF_1X1_DATA_URL + + //#when + const result = parseImageDimensions(dataUrl, "image/gif") + + //#then + expect(result).toEqual({ width: 1, height: 1 }) + }) + + it("parses GIF dimensions from logical screen descriptor", () => { + //#given + const dataUrl = createGifDataUrl(320, 240) + + //#when + const result = parseImageDimensions(dataUrl, "image/gif") + + //#then + expect(result).toEqual({ width: 320, height: 240 }) + }) + + it("returns null for empty input", () => { + //#given + const dataUrl = "" + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toBeNull() + }) + + it("returns null for too-short PNG buffer", () => { + //#given + const dataUrl = "data:image/png;base64,AAAA" + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toBeNull() + }) + + it("returns null for unsupported mime type", () => { + //#given + const dataUrl = PNG_1X1_DATA_URL + + //#when + const result = parseImageDimensions(dataUrl, "image/heic") + + //#then + expect(result).toBeNull() + }) +}) diff --git a/src/hooks/read-image-resizer/image-dimensions.ts b/src/hooks/read-image-resizer/image-dimensions.ts new file mode 100644 index 000000000..56088e97b --- /dev/null +++ b/src/hooks/read-image-resizer/image-dimensions.ts @@ -0,0 +1,187 @@ +import type { ImageDimensions } from "./types" + +import { extractBase64Data } from "../../tools/look-at/mime-type-inference" + +function toImageDimensions(width: number, height: number): ImageDimensions | null { + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return null + } + + if (width <= 0 || height <= 0) { + return null + } + + return { width, height } +} + +function parsePngDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 24) { + return null + } + + const isPngSignature = + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + + if (!isPngSignature || buffer.toString("ascii", 12, 16) !== "IHDR") { + return null + } + + const width = buffer.readUInt32BE(16) + const height = buffer.readUInt32BE(20) + return toImageDimensions(width, height) +} + +function parseGifDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 10) { + return null + } + + if (buffer.toString("ascii", 0, 4) !== "GIF8") { + return null + } + + const width = buffer.readUInt16LE(6) + const height = buffer.readUInt16LE(8) + return toImageDimensions(width, height) +} + +function parseJpegDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null + } + + let offset = 2 + + while (offset < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1 + continue + } + + while (offset < buffer.length && buffer[offset] === 0xff) { + offset += 1 + } + + if (offset >= buffer.length) { + return null + } + + const marker = buffer[offset] + offset += 1 + + if (marker === 0xd9 || marker === 0xda) { + break + } + + if (offset + 1 >= buffer.length) { + return null + } + + const segmentLength = buffer.readUInt16BE(offset) + if (segmentLength < 2) { + return null + } + + if ((marker === 0xc0 || marker === 0xc2) && offset + 7 < buffer.length) { + const height = buffer.readUInt16BE(offset + 3) + const width = buffer.readUInt16BE(offset + 5) + return toImageDimensions(width, height) + } + + offset += segmentLength + } + + return null +} + +function readUInt24LE(buffer: Buffer, offset: number): number { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) +} + +function parseWebpDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 16) { + return null + } + + if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WEBP") { + return null + } + + const chunkType = buffer.toString("ascii", 12, 16) + + if (chunkType === "VP8 ") { + if (buffer[23] !== 0x9d || buffer[24] !== 0x01 || buffer[25] !== 0x2a) { + return null + } + + const width = buffer.readUInt16LE(26) & 0x3fff + const height = buffer.readUInt16LE(28) & 0x3fff + return toImageDimensions(width, height) + } + + if (chunkType === "VP8L") { + if (buffer.length < 25 || buffer[20] !== 0x2f) { + return null + } + + const bits = buffer.readUInt32LE(21) + const width = (bits & 0x3fff) + 1 + const height = ((bits >>> 14) & 0x3fff) + 1 + return toImageDimensions(width, height) + } + + if (chunkType === "VP8X") { + const width = readUInt24LE(buffer, 24) + 1 + const height = readUInt24LE(buffer, 27) + 1 + return toImageDimensions(width, height) + } + + return null +} + +export function parseImageDimensions(base64DataUrl: string, mimeType: string): ImageDimensions | null { + try { + if (!base64DataUrl || !mimeType) { + return null + } + + const rawBase64 = extractBase64Data(base64DataUrl) + if (!rawBase64) { + return null + } + + const buffer = Buffer.from(rawBase64, "base64") + if (buffer.length === 0) { + return null + } + + const normalizedMime = mimeType.toLowerCase() + + if (normalizedMime === "image/png") { + return parsePngDimensions(buffer) + } + + if (normalizedMime === "image/gif") { + return parseGifDimensions(buffer) + } + + if (normalizedMime === "image/jpeg" || normalizedMime === "image/jpg") { + return parseJpegDimensions(buffer) + } + + if (normalizedMime === "image/webp") { + return parseWebpDimensions(buffer) + } + + return null + } catch { + return null + } +} diff --git a/src/hooks/read-image-resizer/image-resizer.test.ts b/src/hooks/read-image-resizer/image-resizer.test.ts new file mode 100644 index 000000000..a885932b3 --- /dev/null +++ b/src/hooks/read-image-resizer/image-resizer.test.ts @@ -0,0 +1,132 @@ +/// + +import { afterEach, describe, expect, it, mock } from "bun:test" + +const PNG_1X1_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + +type ImageResizerModule = typeof import("./image-resizer") + +async function importFreshImageResizerModule(): Promise { + return import(`./image-resizer?test-${Date.now()}-${Math.random()}`) +} + +describe("calculateTargetDimensions", () => { + it("returns null when dimensions are already within limits", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(800, 600) + + //#then + expect(result).toBeNull() + }) + + it("returns null at exact long-edge boundary", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(1568, 1000) + + //#then + expect(result).toBeNull() + }) + + it("scales landscape dimensions by max long edge", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(3000, 2000) + + //#then + expect(result).toEqual({ + width: 1568, + height: Math.floor(2000 * (1568 / 3000)), + }) + }) + + it("scales portrait dimensions by max long edge", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(2000, 3000) + + //#then + expect(result).toEqual({ + width: Math.floor(2000 * (1568 / 3000)), + height: 1568, + }) + }) + + it("scales square dimensions to exact target", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(4000, 4000) + + //#then + expect(result).toEqual({ width: 1568, height: 1568 }) + }) + + it("uses custom maxLongEdge when provided", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(2000, 1000, 1000) + + //#then + expect(result).toEqual({ width: 1000, height: 500 }) + }) +}) + +describe("resizeImage", () => { + afterEach(() => { + mock.restore() + }) + + it("returns null when sharp import fails", async () => { + //#given + mock.module("sharp", () => { + throw new Error("sharp unavailable") + }) + const { resizeImage } = await importFreshImageResizerModule() + + //#when + const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { + width: 1, + height: 1, + }) + + //#then + expect(result).toBeNull() + }) + + it("returns null when sharp throws during resize", async () => { + //#given + const mockSharpFactory = mock(() => ({ + resize: () => { + throw new Error("resize failed") + }, + })) + + mock.module("sharp", () => ({ + default: mockSharpFactory, + })) + const { resizeImage } = await importFreshImageResizerModule() + + //#when + const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { + width: 1, + height: 1, + }) + + //#then + expect(result).toBeNull() + }) +}) diff --git a/src/hooks/read-image-resizer/image-resizer.ts b/src/hooks/read-image-resizer/image-resizer.ts new file mode 100644 index 000000000..7ced5a9e8 --- /dev/null +++ b/src/hooks/read-image-resizer/image-resizer.ts @@ -0,0 +1,184 @@ +import type { ImageDimensions, ResizeResult } from "./types" +import { extractBase64Data } from "../../tools/look-at/mime-type-inference" +import { log } from "../../shared" + +const ANTHROPIC_MAX_LONG_EDGE = 1568 +const ANTHROPIC_MAX_FILE_SIZE = 5 * 1024 * 1024 + +type SharpFormat = "jpeg" | "png" | "gif" | "webp" + +interface SharpMetadata { + width?: number + height?: number +} + +interface SharpInstance { + resize(width: number, height: number, options: { fit: "inside" }): SharpInstance + toFormat(format: SharpFormat, options?: { quality?: number }): SharpInstance + toBuffer(): Promise + metadata(): Promise +} + +type SharpFactory = (input: Buffer) => SharpInstance + +function resolveSharpFactory(sharpModule: unknown): SharpFactory | null { + if (typeof sharpModule === "function") { + return sharpModule as SharpFactory + } + + if (!sharpModule || typeof sharpModule !== "object") { + return null + } + + const defaultExport = Reflect.get(sharpModule, "default") + return typeof defaultExport === "function" ? (defaultExport as SharpFactory) : null +} + +function resolveSharpFormat(mimeType: string): SharpFormat { + const normalizedMime = mimeType.toLowerCase() + if (normalizedMime === "image/png") { + return "png" + } + if (normalizedMime === "image/gif") { + return "gif" + } + if (normalizedMime === "image/webp") { + return "webp" + } + return "jpeg" +} + +function canAdjustQuality(format: SharpFormat): boolean { + return format === "jpeg" || format === "webp" +} + +function toDimensions(metadata: SharpMetadata): ImageDimensions | null { + const { width, height } = metadata + if (!width || !height) { + return null + } + return { width, height } +} + +async function renderResizedBuffer(args: { + sharpFactory: SharpFactory + inputBuffer: Buffer + target: ImageDimensions + format: SharpFormat + quality?: number +}): Promise { + const { sharpFactory, inputBuffer, target, format, quality } = args + return sharpFactory(inputBuffer) + .resize(target.width, target.height, { fit: "inside" }) + .toFormat(format, quality ? { quality } : undefined) + .toBuffer() +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function calculateTargetDimensions( + width: number, + height: number, + maxLongEdge = ANTHROPIC_MAX_LONG_EDGE, +): ImageDimensions | null { + if (width <= 0 || height <= 0 || maxLongEdge <= 0) { + return null + } + + const longEdge = Math.max(width, height) + if (longEdge <= maxLongEdge) { + return null + } + + if (width >= height) { + return { + width: maxLongEdge, + height: Math.max(1, Math.floor((height * maxLongEdge) / width)), + } + } + + return { + width: Math.max(1, Math.floor((width * maxLongEdge) / height)), + height: maxLongEdge, + } +} + +export async function resizeImage( + base64DataUrl: string, + mimeType: string, + target: ImageDimensions, +): Promise { + try { + const sharpModuleName = "sharp" + const sharpModule = await import(sharpModuleName).catch(() => null) + if (!sharpModule) { + log("[read-image-resizer] sharp unavailable, skipping resize") + return null + } + + const sharpFactory = resolveSharpFactory(sharpModule) + if (!sharpFactory) { + log("[read-image-resizer] sharp import has unexpected shape") + return null + } + + const rawBase64 = extractBase64Data(base64DataUrl) + if (!rawBase64) { + return null + } + + const inputBuffer = Buffer.from(rawBase64, "base64") + if (inputBuffer.length === 0) { + return null + } + + const original = toDimensions(await sharpFactory(inputBuffer).metadata()) + if (!original) { + return null + } + + const format = resolveSharpFormat(mimeType) + let resizedBuffer = await renderResizedBuffer({ + sharpFactory, + inputBuffer, + target, + format, + }) + + if (resizedBuffer.length > ANTHROPIC_MAX_FILE_SIZE && canAdjustQuality(format)) { + for (const quality of [80, 60, 40]) { + resizedBuffer = await renderResizedBuffer({ + sharpFactory, + inputBuffer, + target, + format, + quality, + }) + + if (resizedBuffer.length <= ANTHROPIC_MAX_FILE_SIZE) { + break + } + } + } + + const resized = toDimensions(await sharpFactory(resizedBuffer).metadata()) + if (!resized) { + return null + } + + return { + resizedDataUrl: `data:${mimeType};base64,${resizedBuffer.toString("base64")}`, + original, + resized, + } + } catch (error) { + log("[read-image-resizer] resize failed", { + error: getErrorMessage(error), + mimeType, + target, + }) + return null + } +} diff --git a/src/hooks/read-image-resizer/index.ts b/src/hooks/read-image-resizer/index.ts new file mode 100644 index 000000000..d6fbcc25b --- /dev/null +++ b/src/hooks/read-image-resizer/index.ts @@ -0,0 +1 @@ +export { createReadImageResizerHook } from "./hook" diff --git a/src/hooks/read-image-resizer/types.ts b/src/hooks/read-image-resizer/types.ts new file mode 100644 index 000000000..4b6a7b05c --- /dev/null +++ b/src/hooks/read-image-resizer/types.ts @@ -0,0 +1,16 @@ +export interface ImageDimensions { + width: number + height: number +} + +export interface ImageAttachment { + mime: string + url: string + filename?: string +} + +export interface ResizeResult { + resizedDataUrl: string + original: ImageDimensions + resized: ImageDimensions +} diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 492dd17db..758b78c59 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, + createReadImageResizerHook, createJsonErrorRecoveryHook, } from "../../hooks" import { @@ -33,6 +34,7 @@ export type ToolGuardHooks = { writeExistingFileGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null jsonErrorRecovery: ReturnType | null + readImageResizer: ReturnType | null } export function createToolGuardHooks(args: { @@ -105,6 +107,10 @@ export function createToolGuardHooks(args: { ? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx)) : null + const readImageResizer = isHookEnabled("read-image-resizer") + ? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx)) + : null + return { commentChecker, toolOutputTruncator, @@ -116,5 +122,6 @@ export function createToolGuardHooks(args: { writeExistingFileGuard, hashlineReadEnhancer, jsonErrorRecovery, + readImageResizer, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index fa6c8dade..58717c9bb 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -43,6 +43,7 @@ export function createToolExecuteAfterHandler(args: { await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) + await hooks.readImageResizer?.["tool.execute.after"]?.(input, output) await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output) await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output) } From 418cf8529f1f871975e4d6d500549f197e41e747 Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 01:43:47 +0100 Subject: [PATCH 46/62] fix(glob): use cwd-relative search for ripgrep to fix directory prefix patterns Ripgrep's --glob flag silently returns zero results when the search target is an absolute path and the pattern contains directory prefixes (e.g. 'apps/backend/**/*.ts' with '/project'). This is a known ripgrep behavior where glob matching fails against paths rooted at absolute arguments. Fix by running ripgrep with cwd set to the search path and '.' as the search target, matching how the find backend already operates. Ripgrep then sees relative paths internally, so directory-prefixed globs match correctly. Output paths are resolved back to absolute via resolve(). --- src/tools/glob/cli.ts | 8 ++++---- src/tools/glob/tools.ts | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index b621383a6..996133383 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path" import { spawn } from "bun" import { resolveGrepCli, @@ -119,10 +120,9 @@ async function runRgFilesInternal( if (isRg) { const args = buildRgArgs(options) - const paths = options.paths?.length ? options.paths : ["."] - args.push(...paths) + cwd = options.paths?.[0] || "." + args.push(".") command = [cli.path, ...args] - cwd = undefined } else if (isWindows) { command = buildPowerShellCommand(options) cwd = undefined @@ -177,7 +177,7 @@ async function runRgFilesInternal( let filePath: string if (isRg) { - filePath = line + filePath = cwd ? resolve(cwd, line) : line } else if (isWindows) { filePath = line.trim() } else { diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index d808377b2..cdfa983b4 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -29,12 +29,11 @@ export function createGlobTools(ctx: PluginInput): Record const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory const searchPath = args.path ? resolve(dir, args.path) : dir - const paths = [searchPath] const result = await runRgFiles( { pattern: args.pattern, - paths, + paths: [searchPath], }, cli ) From 6e9f27350dd449dcd419e7787b66b60ac8cb7cfa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 13:30:14 +0900 Subject: [PATCH 47/62] fix(hook-message-injector): use monotonic counter for deterministic message/part IDs --- .../hook-message-injector/injector.test.ts | 34 +++++++++++++++++++ .../hook-message-injector/injector.ts | 15 ++++---- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index fffdf5a7d..c66d18d95 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -4,6 +4,8 @@ import { findFirstMessageWithAgent, findNearestMessageWithFieldsFromSDK, findFirstMessageWithAgentFromSDK, + generateMessageId, + generatePartId, injectHookMessage, } from "./injector" import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection" @@ -192,6 +194,38 @@ describe("findFirstMessageWithAgentFromSDK", () => { }) }) +describe("generateMessageId", () => { + it("returns deterministic sequential IDs with fixed format", () => { + // given + const format = /^msg_\d{12}$/ + + // when + const firstId = generateMessageId() + const secondId = generateMessageId() + + // then + expect(firstId).toMatch(format) + expect(secondId).toMatch(format) + expect(Number(secondId.slice(4))).toBe(Number(firstId.slice(4)) + 1) + }) +}) + +describe("generatePartId", () => { + it("returns deterministic sequential IDs with fixed format", () => { + // given + const format = /^prt_\d{12}$/ + + // when + const firstId = generatePartId() + const secondId = generatePartId() + + // then + expect(firstId).toMatch(format) + expect(secondId).toMatch(format) + expect(Number(secondId.slice(4))).toBe(Number(firstId.slice(4)) + 1) + }) +}) + describe("injectHookMessage", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index 8f4e0d57b..68520007e 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -29,6 +29,9 @@ interface SDKMessage { } } +let messageCounter = 0 +let partCounter = 0 + function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { const info = msg.info if (!info) return null @@ -204,16 +207,12 @@ export function findFirstMessageWithAgent(messageDir: string): string | null { return null } -function generateMessageId(): string { - const timestamp = Date.now().toString(16) - const random = Math.random().toString(36).substring(2, 14) - return `msg_${timestamp}${random}` +export function generateMessageId(): string { + return `msg_${String(++messageCounter).padStart(12, "0")}` } -function generatePartId(): string { - const timestamp = Date.now().toString(16) - const random = Math.random().toString(36).substring(2, 10) - return `prt_${timestamp}${random}` +export function generatePartId(): string { + return `prt_${String(++partCounter).padStart(12, "0")}` } function getOrCreateMessageDir(sessionID: string): string { From 4d8360c72f246780dea3cf5ecccba94b6af86807 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 13:30:49 +0900 Subject: [PATCH 48/62] fix(context-injector): use deterministic synthetic part ID for cache stability --- .../context-injector/injector.test.ts | 45 +++++++++++++++++++ src/features/context-injector/injector.ts | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/features/context-injector/injector.test.ts b/src/features/context-injector/injector.test.ts index 6fe9e7e81..09de376fe 100644 --- a/src/features/context-injector/injector.test.ts +++ b/src/features/context-injector/injector.test.ts @@ -64,6 +64,51 @@ describe("createContextInjectorMessagesTransformHook", () => { expect(output.messages[2].parts[1].text).toBe("Second message") }) + it("uses deterministic synthetic part ID across repeated transforms", async () => { + // given + const hook = createContextInjectorMessagesTransformHook(collector) + const sessionID = "ses_transform_deterministic" + const baseMessage = createMockMessage("user", "Stable message", sessionID) + + collector.register(sessionID, { + id: "ctx-1", + source: "keyword-detector", + content: "Injected context", + }) + const firstOutput = { + messages: [structuredClone(baseMessage)], + } + + // when + await hook["experimental.chat.messages.transform"]!({}, firstOutput) + + // then + const firstSyntheticPart = firstOutput.messages[0].parts[0] + expect( + "synthetic" in firstSyntheticPart && firstSyntheticPart.synthetic === true + ).toBe(true) + + // given + collector.register(sessionID, { + id: "ctx-2", + source: "keyword-detector", + content: "Injected context", + }) + const secondOutput = { + messages: [structuredClone(baseMessage)], + } + + // when + await hook["experimental.chat.messages.transform"]!({}, secondOutput) + + // then + const secondSyntheticPart = secondOutput.messages[0].parts[0] + expect( + "synthetic" in secondSyntheticPart && secondSyntheticPart.synthetic === true + ).toBe(true) + expect(secondSyntheticPart.id).toBe(firstSyntheticPart.id) + }) + it("does nothing when no pending context", async () => { // given const hook = createContextInjectorMessagesTransformHook(collector) diff --git a/src/features/context-injector/injector.ts b/src/features/context-injector/injector.ts index ca676a11e..8a52de914 100644 --- a/src/features/context-injector/injector.ts +++ b/src/features/context-injector/injector.ts @@ -148,7 +148,7 @@ export function createContextInjectorMessagesTransformHook( // synthetic part pattern (minimal fields) const syntheticPart = { - id: `synthetic_hook_${Date.now()}`, + id: `synthetic_hook_${sessionID}`, messageID: lastUserMessage.info.id, sessionID: (lastUserMessage.info as { sessionID?: string }).sessionID ?? "", type: "text" as const, From 7c9f507dadc5f5933c22805c4ed0ae2efd8a8f82 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 28 Feb 2026 13:30:57 +0900 Subject: [PATCH 49/62] fix(context-injector): use monotonic registration order instead of timestamp for deterministic sorting --- .../context-injector/collector.test.ts | 39 +++++++++++++++++++ src/features/context-injector/collector.ts | 6 ++- src/features/context-injector/types.ts | 4 +- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/features/context-injector/collector.test.ts b/src/features/context-injector/collector.test.ts index 695ff4af8..4001b5483 100644 --- a/src/features/context-injector/collector.test.ts +++ b/src/features/context-injector/collector.test.ts @@ -205,6 +205,45 @@ describe("ContextCollector", () => { const ids = pending.entries.map((e) => e.id) expect(ids).toEqual(["first", "second", "third"]) }) + + it("keeps registration order even when Date.now values are not monotonic", () => { + // given + const sessionID = "ses_order_non_monotonic_time" + const originalDateNow = Date.now + const mockedTimestamps = [300, 100, 200] + let timestampIndex = 0 + Date.now = () => mockedTimestamps[timestampIndex++] ?? 0 + + try { + collector.register(sessionID, { + id: "first", + source: "custom", + content: "First", + priority: "normal", + }) + collector.register(sessionID, { + id: "second", + source: "custom", + content: "Second", + priority: "normal", + }) + collector.register(sessionID, { + id: "third", + source: "custom", + content: "Third", + priority: "normal", + }) + } finally { + Date.now = originalDateNow + } + + // when + const pending = collector.getPending(sessionID) + + // then + const ids = pending.entries.map((entry) => entry.id) + expect(ids).toEqual(["first", "second", "third"]) + }) }) describe("consume", () => { diff --git a/src/features/context-injector/collector.ts b/src/features/context-injector/collector.ts index af60e4196..f1b9f61ab 100644 --- a/src/features/context-injector/collector.ts +++ b/src/features/context-injector/collector.ts @@ -14,6 +14,8 @@ const PRIORITY_ORDER: Record = { const CONTEXT_SEPARATOR = "\n\n---\n\n" +let registrationCounter = 0 + export class ContextCollector { private sessions: Map> = new Map() @@ -30,7 +32,7 @@ export class ContextCollector { source: options.source, content: options.content, priority: options.priority ?? "normal", - timestamp: Date.now(), + registrationOrder: ++registrationCounter, metadata: options.metadata, } @@ -77,7 +79,7 @@ export class ContextCollector { return entries.sort((a, b) => { const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority] if (priorityDiff !== 0) return priorityDiff - return a.timestamp - b.timestamp + return a.registrationOrder - b.registrationOrder }) } } diff --git a/src/features/context-injector/types.ts b/src/features/context-injector/types.ts index c203be981..23030d0e9 100644 --- a/src/features/context-injector/types.ts +++ b/src/features/context-injector/types.ts @@ -27,8 +27,8 @@ export interface ContextEntry { content: string /** Priority for ordering (default: normal) */ priority: ContextPriority - /** Timestamp when registered */ - timestamp: number + /** Monotonic order when registered */ + registrationOrder: number /** Optional metadata for debugging/logging */ metadata?: Record } From 9a505a33ac0c1020593870f2875c1c90f20a1586 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 01:16:35 +0000 Subject: [PATCH 50/62] @laciferin2024 has signed the CLA in code-yeongyu/oh-my-opencode#2222 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index 45b1052a0..e4664590a 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1807,6 +1807,14 @@ "created_at": "2026-02-27T22:38:18Z", "repoId": 1108837393, "pullRequestNo": 2201 + }, + { + "name": "laciferin2024", + "id": 170102251, + "comment_id": 3978786169, + "created_at": "2026-03-01T01:16:25Z", + "repoId": 1108837393, + "pullRequestNo": 2222 } ] } \ No newline at end of file From 1a25b251c336ad183a6f2fab3962d5f1d912174b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 08:13:56 +0000 Subject: [PATCH 51/62] @DEAN-Cherry has signed the CLA in code-yeongyu/oh-my-opencode#2227 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index e4664590a..da4d7fed0 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1815,6 +1815,14 @@ "created_at": "2026-03-01T01:16:25Z", "repoId": 1108837393, "pullRequestNo": 2222 + }, + { + "name": "DEAN-Cherry", + "id": 76607677, + "comment_id": 3979468463, + "created_at": "2026-03-01T08:13:43Z", + "repoId": 1108837393, + "pullRequestNo": 2227 } ] } \ No newline at end of file From a6955d7d1433e6fec937100e12f5ccf954fbd195 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:52:22 +0000 Subject: [PATCH 52/62] @Chocothin has signed the CLA in code-yeongyu/oh-my-opencode#2230 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index da4d7fed0..a7e86e11e 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1823,6 +1823,14 @@ "created_at": "2026-03-01T08:13:43Z", "repoId": 1108837393, "pullRequestNo": 2227 + }, + { + "name": "Chocothin", + "id": 99174213, + "comment_id": 3980002001, + "created_at": "2026-03-01T13:52:10Z", + "repoId": 1108837393, + "pullRequestNo": 2230 } ] } \ No newline at end of file From a666612354353412a9915b418ab5625964c8d2a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:19:41 +0000 Subject: [PATCH 53/62] @mathew-cf has signed the CLA in code-yeongyu/oh-my-opencode#2233 --- signatures/cla.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/signatures/cla.json b/signatures/cla.json index a7e86e11e..761512612 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1831,6 +1831,14 @@ "created_at": "2026-03-01T13:52:10Z", "repoId": 1108837393, "pullRequestNo": 2230 + }, + { + "name": "mathew-cf", + "id": 68972715, + "comment_id": 3980951159, + "created_at": "2026-03-01T20:19:31Z", + "repoId": 1108837393, + "pullRequestNo": 2233 } ] } \ No newline at end of file From 682a3c8515759ee6bc16a724b0e90bf973f966e2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 14:48:35 +0900 Subject: [PATCH 54/62] fix(hooks): prevent SSRF via URL scheme validation and extend disable mechanism to HTTP hooks - Restrict HTTP hook URLs to http: and https: schemes only (blocks file://, data://, ftp://) - Extend hook disable config to cover HTTP hooks by matching against hook URL identifier - Update all 5 hook executors (pre-tool-use, post-tool-use, stop, pre-compact, user-prompt-submit) - Add 6 new tests for URL scheme validation (file, data, ftp rejection + http, https, invalid URL) --- .../execute-http-hook.test.ts | 78 +++++++++++++++++-- .../claude-code-hooks/execute-http-hook.ts | 13 ++++ src/hooks/claude-code-hooks/post-tool-use.ts | 6 +- src/hooks/claude-code-hooks/pre-compact.ts | 6 +- src/hooks/claude-code-hooks/pre-tool-use.ts | 6 +- src/hooks/claude-code-hooks/stop.ts | 7 +- .../claude-code-hooks/user-prompt-submit.ts | 7 +- 7 files changed, 102 insertions(+), 21 deletions(-) diff --git a/src/hooks/claude-code-hooks/execute-http-hook.test.ts b/src/hooks/claude-code-hooks/execute-http-hook.test.ts index bc7e1f598..682611875 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.test.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -33,7 +33,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) expect(mockFetch).toHaveBeenCalledTimes(1) - const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe("http://localhost:8080/hooks/pre-tool-use") expect(options.method).toBe("POST") expect(options.body).toBe(stdinData) @@ -44,7 +44,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, stdinData) - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Content-Type"]).toBe("application/json") }) @@ -72,7 +72,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -88,7 +88,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer secret-123") }) @@ -104,7 +104,7 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] const headers = options.headers as Record expect(headers["Authorization"]).toBe("Bearer ") }) @@ -121,11 +121,77 @@ describe("executeHttpHook", () => { await executeHttpHook(hook, "{}") - const [, options] = mockFetch.mock.calls[0] as [string, RequestInit] + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] expect(options.signal).toBeDefined() }) }) + describe("#given hook URL scheme validation", () => { + it("#when URL uses file:// scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "file:///etc/passwd" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "file:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses data: scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "data:text/plain,hello" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "data:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses ftp:// scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "ftp://localhost/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "ftp:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses http:// scheme #then allows hook execution", async () => { + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("#when URL uses https:// scheme #then allows hook execution", async () => { + const hook: HookHttp = { type: "http", url: "https://example.com/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("#when URL is invalid #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "not-a-valid-url" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("HTTP hook URL is invalid: not-a-valid-url") + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + describe("#given a successful HTTP response", () => { it("#when response has JSON body #then returns parsed output", async () => { mockFetch.mockImplementation(() => diff --git a/src/hooks/claude-code-hooks/execute-http-hook.ts b/src/hooks/claude-code-hooks/execute-http-hook.ts index 3ad2c5e57..1e72817cf 100644 --- a/src/hooks/claude-code-hooks/execute-http-hook.ts +++ b/src/hooks/claude-code-hooks/execute-http-hook.ts @@ -2,6 +2,7 @@ import type { HookHttp } from "./types" import type { CommandResult } from "../../shared/command-executor/execute-hook-command" const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30 +const ALLOWED_SCHEMES = new Set(["http:", "https:"]) export function interpolateEnvVars( value: string, @@ -39,6 +40,18 @@ export async function executeHttpHook( hook: HookHttp, stdin: string ): Promise { + try { + const parsed = new URL(hook.url) + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + return { + exitCode: 1, + stderr: `HTTP hook URL scheme "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`, + } + } + } catch { + return { exitCode: 1, stderr: `HTTP hook URL is invalid: ${hook.url}` } + } + const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S const headers = resolveHeaders(hook) diff --git a/src/hooks/claude-code-hooks/post-tool-use.ts b/src/hooks/claude-code-hooks/post-tool-use.ts index b119252c2..3ba1f7208 100644 --- a/src/hooks/claude-code-hooks/post-tool-use.ts +++ b/src/hooks/claude-code-hooks/post-tool-use.ts @@ -96,12 +96,12 @@ export async function executePostToolUseHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) { - log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PostToolUse", hookName, extendedConfig ?? null)) { + log("PostToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName }) continue } - const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) diff --git a/src/hooks/claude-code-hooks/pre-compact.ts b/src/hooks/claude-code-hooks/pre-compact.ts index 09d2425e0..a3aa01b62 100644 --- a/src/hooks/claude-code-hooks/pre-compact.ts +++ b/src/hooks/claude-code-hooks/pre-compact.ts @@ -52,12 +52,12 @@ export async function executePreCompactHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) { - log("PreCompact hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PreCompact", hookName, extendedConfig ?? null)) { + log("PreCompact hook command skipped (disabled by config)", { command: hookName }) continue } - const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index ec16369ec..97bfaf04a 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -79,12 +79,12 @@ export async function executePreToolUseHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) { - log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PreToolUse", hookName, extendedConfig ?? null)) { + log("PreToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName }) continue } - const hookName = getHookIdentifier(hook) if (!firstHookName) firstHookName = hookName const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) diff --git a/src/hooks/claude-code-hooks/stop.ts b/src/hooks/claude-code-hooks/stop.ts index 81cf821b9..5b4423eb8 100644 --- a/src/hooks/claude-code-hooks/stop.ts +++ b/src/hooks/claude-code-hooks/stop.ts @@ -4,7 +4,7 @@ import type { ClaudeHooksConfig, } from "./types" import { findMatchingHooks, log } from "../../shared" -import { dispatchHook } from "./dispatch-hook" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { getTodoPath } from "./todo" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -70,8 +70,9 @@ export async function executeStopHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) { - log("Stop hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("Stop", hookName, extendedConfig ?? null)) { + log("Stop hook command skipped (disabled by config)", { command: hookName }) continue } diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts index 5f1cbc2e4..e714eb6bd 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -4,7 +4,7 @@ import type { ClaudeHooksConfig, } from "./types" import { findMatchingHooks, log } from "../../shared" -import { dispatchHook } from "./dispatch-hook" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" const USER_PROMPT_SUBMIT_TAG_OPEN = "" @@ -82,8 +82,9 @@ export async function executeUserPromptSubmitHooks( for (const hook of matcher.hooks) { if (hook.type !== "command" && hook.type !== "http") continue - if (hook.type === "command" && isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) { - log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("UserPromptSubmit", hookName, extendedConfig ?? null)) { + log("UserPromptSubmit hook command skipped (disabled by config)", { command: hookName }) continue } From 1a9e7eb305409a42b54028b9d12549cbe45897d8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 14:48:39 +0900 Subject: [PATCH 55/62] fix(hook-message-injector): add process-unique prefix to message/part IDs to prevent storage collisions IDs now include a random 8-hex-char prefix per process (e.g. msg_a1b2c3d4_000001) preventing collisions when counters reset across process restarts. --- src/features/hook-message-injector/injector.test.ts | 10 ++++++---- src/features/hook-message-injector/injector.ts | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index c66d18d95..6481e8851 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -197,7 +197,7 @@ describe("findFirstMessageWithAgentFromSDK", () => { describe("generateMessageId", () => { it("returns deterministic sequential IDs with fixed format", () => { // given - const format = /^msg_\d{12}$/ + const format = /^msg_[0-9a-f]{8}_\d{6}$/ // when const firstId = generateMessageId() @@ -206,14 +206,15 @@ describe("generateMessageId", () => { // then expect(firstId).toMatch(format) expect(secondId).toMatch(format) - expect(Number(secondId.slice(4))).toBe(Number(firstId.slice(4)) + 1) + expect(secondId.split("_")[1]).toBe(firstId.split("_")[1]) + expect(Number(secondId.split("_")[2])).toBe(Number(firstId.split("_")[2]) + 1) }) }) describe("generatePartId", () => { it("returns deterministic sequential IDs with fixed format", () => { // given - const format = /^prt_\d{12}$/ + const format = /^prt_[0-9a-f]{8}_\d{6}$/ // when const firstId = generatePartId() @@ -222,7 +223,8 @@ describe("generatePartId", () => { // then expect(firstId).toMatch(format) expect(secondId).toMatch(format) - expect(Number(secondId.slice(4))).toBe(Number(firstId.slice(4)) + 1) + expect(secondId.split("_")[1]).toBe(firstId.split("_")[1]) + expect(Number(secondId.split("_")[2])).toBe(Number(firstId.split("_")[2]) + 1) }) }) diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index 68520007e..4d43f025b 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" @@ -29,6 +30,7 @@ interface SDKMessage { } } +const processPrefix = randomBytes(4).toString("hex") let messageCounter = 0 let partCounter = 0 @@ -208,11 +210,11 @@ export function findFirstMessageWithAgent(messageDir: string): string | null { } export function generateMessageId(): string { - return `msg_${String(++messageCounter).padStart(12, "0")}` + return `msg_${processPrefix}_${String(++messageCounter).padStart(6, "0")}` } export function generatePartId(): string { - return `prt_${String(++partCounter).padStart(12, "0")}` + return `prt_${processPrefix}_${String(++partCounter).padStart(6, "0")}` } function getOrCreateMessageDir(sessionID: string): string { From 0dd9ac43ea3fcf759fcb5589ea403e39a619b34f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 14:48:43 +0900 Subject: [PATCH 56/62] perf(read-image-resizer): decode only first 32KB of base64 for dimension parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously decoded entire image buffer to read headers. Now slices base64 to 32KB prefix before decoding β€” sufficient for PNG/GIF/WebP/JPEG headers. Dramatically reduces memory allocation for large images. --- .../image-dimensions.test.ts | 18 ++++++++++++++++++ .../read-image-resizer/image-dimensions.ts | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/hooks/read-image-resizer/image-dimensions.test.ts b/src/hooks/read-image-resizer/image-dimensions.test.ts index 47beb2714..72fa2dbb7 100644 --- a/src/hooks/read-image-resizer/image-dimensions.test.ts +++ b/src/hooks/read-image-resizer/image-dimensions.test.ts @@ -28,6 +28,13 @@ function createGifDataUrl(width: number, height: number): string { return `data:image/gif;base64,${buf.toString("base64")}` } +function createLargePngDataUrl(width: number, height: number, extraBase64Chars: number): string { + const baseDataUrl = createPngDataUrl(width, height) + const base64Data = baseDataUrl.slice(baseDataUrl.indexOf(",") + 1) + const paddedBase64 = `${base64Data}${"A".repeat(extraBase64Chars)}` + return `data:image/png;base64,${paddedBase64}` +} + describe("parseImageDimensions", () => { it("parses PNG 1x1 dimensions", () => { //#given @@ -51,6 +58,17 @@ describe("parseImageDimensions", () => { expect(result).toEqual({ width: 3000, height: 2000 }) }) + it("parses PNG dimensions from a very large base64 payload", () => { + //#given + const dataUrl = createLargePngDataUrl(4096, 2160, 10 * 1024 * 1024) + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toEqual({ width: 4096, height: 2160 }) + }) + it("parses GIF 1x1 dimensions", () => { //#given const dataUrl = GIF_1X1_DATA_URL diff --git a/src/hooks/read-image-resizer/image-dimensions.ts b/src/hooks/read-image-resizer/image-dimensions.ts index 56088e97b..0bb411905 100644 --- a/src/hooks/read-image-resizer/image-dimensions.ts +++ b/src/hooks/read-image-resizer/image-dimensions.ts @@ -2,6 +2,9 @@ import type { ImageDimensions } from "./types" import { extractBase64Data } from "../../tools/look-at/mime-type-inference" +const HEADER_BYTES = 32_768 +const HEADER_BASE64_CHARS = Math.ceil(HEADER_BYTES / 3) * 4 + function toImageDimensions(width: number, height: number): ImageDimensions | null { if (!Number.isFinite(width) || !Number.isFinite(height)) { return null @@ -157,7 +160,8 @@ export function parseImageDimensions(base64DataUrl: string, mimeType: string): I return null } - const buffer = Buffer.from(rawBase64, "base64") + const headerBase64 = rawBase64.length > HEADER_BASE64_CHARS ? rawBase64.slice(0, HEADER_BASE64_CHARS) : rawBase64 + const buffer = Buffer.from(headerBase64, "base64") if (buffer.length === 0) { return null } From 3db46a58a7038099e428d765c478f0d653ab47cf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 15:20:02 +0900 Subject: [PATCH 57/62] feat(hashline): change hashline_edit default from true to false Hashline edit tool and companion hooks now require explicit opt-in via `"hashline_edit": true` in config. Previously enabled by default. - tool-registry: hashline edit tool not registered unless opted in - create-tool-guard-hooks: hashline-read-enhancer disabled by default - Updated config schema comment and documentation - Added TDD tests for default behavior --- docs/reference/configuration.md | 6 +-- src/config/schema/oh-my-opencode-config.ts | 2 +- src/plugin/hooks/create-tool-guard-hooks.ts | 2 +- src/plugin/tool-execute-before.test.ts | 51 +++++++++++++++++++++ src/plugin/tool-registry.ts | 2 +- 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c01865a5f..28eba1193 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -573,13 +573,13 @@ Define `fallback_models` per agent or category: ### Hashline Edit -Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Enabled by default. +Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Disabled by default. ```json -{ "hashline_edit": false } +{ "hashline_edit": true } ``` -When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Disable them individually via `disabled_hooks`. +When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Opt-in by setting `hashline_edit: true`. Disable the companion hooks individually via `disabled_hooks` if needed. ### Experimental diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 52e7d461e..200f7289c 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -33,7 +33,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ disabled_commands: z.array(BuiltinCommandNameSchema).optional(), /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ disabled_tools: z.array(z.string()).optional(), - /** Enable hashline_edit tool/hook integrations (default: true at call site) */ + /** Enable hashline_edit tool/hook integrations (default: false) */ hashline_edit: z.boolean().optional(), /** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */ model_fallback: z.boolean().optional(), diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 758b78c59..3e909e785 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -100,7 +100,7 @@ export function createToolGuardHooks(args: { : null const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer") - ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } })) + ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? false } })) : null const jsonErrorRecovery = isHookEnabled("json-error-recovery") diff --git a/src/plugin/tool-execute-before.test.ts b/src/plugin/tool-execute-before.test.ts index b3cd3f7fe..7383bfb63 100644 --- a/src/plugin/tool-execute-before.test.ts +++ b/src/plugin/tool-execute-before.test.ts @@ -1,5 +1,6 @@ const { describe, expect, test } = require("bun:test") const { createToolExecuteBeforeHandler } = require("./tool-execute-before") +const { createToolRegistry } = require("./tool-registry") describe("createToolExecuteBeforeHandler", () => { test("does not execute subagent question blocker hook for question tool", async () => { @@ -219,4 +220,54 @@ describe("createToolExecuteBeforeHandler", () => { }) }) +describe("createToolRegistry", () => { + function createRegistryInput(overrides = {}) { + return { + ctx: { + directory: process.cwd(), + client: {}, + }, + pluginConfig: { + ...overrides, + }, + managers: { + backgroundManager: {}, + tmuxSessionManager: {}, + skillMcpManager: {}, + }, + skillContext: { + mergedSkills: [], + availableSkills: [], + browserProvider: "playwright", + disabledSkills: new Set(), + }, + availableCategories: [], + } + } + + describe("#given hashline_edit is undefined", () => { + describe("#when creating tool registry", () => { + test("#then should not register edit tool", () => { + const result = createToolRegistry(createRegistryInput()) + + expect(result.filteredTools.edit).toBeUndefined() + }) + }) + }) + + describe("#given hashline_edit is true", () => { + describe("#when creating tool registry", () => { + test("#then should register edit tool", () => { + const result = createToolRegistry( + createRegistryInput({ + hashline_edit: true, + }), + ) + + expect(result.filteredTools.edit).toBeDefined() + }) + }) + }) +}) + export {} diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 21d7901f4..7efce95f2 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -113,7 +113,7 @@ export function createToolRegistry(args: { } : {} - const hashlineEnabled = pluginConfig.hashline_edit ?? true + const hashlineEnabled = pluginConfig.hashline_edit ?? false const hashlineToolsRecord: Record = hashlineEnabled ? { edit: createHashlineEditTool() } : {} From f27fd9a6de83c7206f1eaee89add1fc642f1da0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 06:27:47 +0000 Subject: [PATCH 58/62] release: v3.10.0 --- package.json | 24 +++++++++---------- packages/darwin-arm64/package.json | 2 +- packages/darwin-x64-baseline/package.json | 2 +- packages/darwin-x64/package.json | 2 +- packages/linux-arm64-musl/package.json | 2 +- packages/linux-arm64/package.json | 2 +- packages/linux-x64-baseline/package.json | 2 +- packages/linux-x64-musl-baseline/package.json | 2 +- packages/linux-x64-musl/package.json | 2 +- packages/linux-x64/package.json | 2 +- packages/windows-x64-baseline/package.json | 2 +- packages/windows-x64/package.json | 2 +- 12 files changed, 23 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 012e6dcc4..5c9581592 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode", - "version": "3.9.0", + "version": "3.10.0", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -75,17 +75,17 @@ "typescript": "^5.7.3" }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.9.0", - "oh-my-opencode-darwin-x64": "3.9.0", - "oh-my-opencode-darwin-x64-baseline": "3.9.0", - "oh-my-opencode-linux-arm64": "3.9.0", - "oh-my-opencode-linux-arm64-musl": "3.9.0", - "oh-my-opencode-linux-x64": "3.9.0", - "oh-my-opencode-linux-x64-baseline": "3.9.0", - "oh-my-opencode-linux-x64-musl": "3.9.0", - "oh-my-opencode-linux-x64-musl-baseline": "3.9.0", - "oh-my-opencode-windows-x64": "3.9.0", - "oh-my-opencode-windows-x64-baseline": "3.9.0" + "oh-my-opencode-darwin-arm64": "3.10.0", + "oh-my-opencode-darwin-x64": "3.10.0", + "oh-my-opencode-darwin-x64-baseline": "3.10.0", + "oh-my-opencode-linux-arm64": "3.10.0", + "oh-my-opencode-linux-arm64-musl": "3.10.0", + "oh-my-opencode-linux-x64": "3.10.0", + "oh-my-opencode-linux-x64-baseline": "3.10.0", + "oh-my-opencode-linux-x64-musl": "3.10.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.10.0", + "oh-my-opencode-windows-x64": "3.10.0", + "oh-my-opencode-windows-x64-baseline": "3.10.0" }, "trustedDependencies": [ "@ast-grep/cli", diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json index af9f8d5ac..c6e5946a2 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-arm64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64-baseline/package.json b/packages/darwin-x64-baseline/package.json index 2cef9b7f3..3c8cab0f5 100644 --- a/packages/darwin-x64-baseline/package.json +++ b/packages/darwin-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json index 42f7e0456..923c58288 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64-musl/package.json b/packages/linux-arm64-musl/package.json index 4de8b4689..874635eb0 100644 --- a/packages/linux-arm64-musl/package.json +++ b/packages/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64-musl", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json index 66d0d1267..a864c9eb6 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-baseline/package.json b/packages/linux-x64-baseline/package.json index 5fd5f2ad2..200b40e04 100644 --- a/packages/linux-x64-baseline/package.json +++ b/packages/linux-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl-baseline/package.json b/packages/linux-x64-musl-baseline/package.json index 679729f73..b55ac1f24 100644 --- a/packages/linux-x64-musl-baseline/package.json +++ b/packages/linux-x64-musl-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl/package.json b/packages/linux-x64-musl/package.json index 376329a3c..eb6a5a136 100644 --- a/packages/linux-x64-musl/package.json +++ b/packages/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json index 889d92324..a0ab8a33e 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64)", "license": "MIT", "repository": { diff --git a/packages/windows-x64-baseline/package.json b/packages/windows-x64-baseline/package.json index 2510abfbc..066dc0bb2 100644 --- a/packages/windows-x64-baseline/package.json +++ b/packages/windows-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/windows-x64/package.json b/packages/windows-x64/package.json index 8909dd7f1..7afaf5b47 100644 --- a/packages/windows-x64/package.json +++ b/packages/windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64)", "license": "MIT", "repository": { From 4b366926d47d57d89fcb830f63f4e0ceb24620ba Mon Sep 17 00:00:00 2001 From: IYODA Atsushi Date: Mon, 2 Mar 2026 16:38:22 +0900 Subject: [PATCH 59/62] refactor(shared): deduplicate model resolution utility functions Extract normalizeModel() (3 identical copies) and normalizeModelID() (2 identical copies) into canonical src/shared/model-normalization.ts. Delete dead-end duplicate model-name-matcher.ts. Update all consumers. --- src/hooks/anthropic-effort/hook.ts | 6 +- src/hooks/think-mode/switcher.ts | 20 +--- src/shared/AGENTS.md | 2 +- src/shared/fallback-model-availability.ts | 2 +- src/shared/index.ts | 1 + src/shared/model-name-matcher.ts | 83 -------------- src/shared/model-normalization.test.ts | 123 +++++++++++++++++++++ src/shared/model-normalization.ts | 8 ++ src/shared/model-resolution-pipeline.ts | 5 +- src/shared/model-resolver.ts | 5 +- src/tools/delegate-task/model-selection.ts | 5 +- 11 files changed, 140 insertions(+), 120 deletions(-) delete mode 100644 src/shared/model-name-matcher.ts create mode 100644 src/shared/model-normalization.test.ts create mode 100644 src/shared/model-normalization.ts diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index 06a754d23..16e2656c2 100644 --- a/src/hooks/anthropic-effort/hook.ts +++ b/src/hooks/anthropic-effort/hook.ts @@ -1,11 +1,7 @@ -import { log } from "../../shared" +import { log, normalizeModelID } from "../../shared" const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i -function normalizeModelID(modelID: string): string { - return modelID.replace(/\.(\d+)/g, "-$1") -} - function isClaudeProvider(providerID: string, modelID: string): boolean { if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true diff --git a/src/hooks/think-mode/switcher.ts b/src/hooks/think-mode/switcher.ts index 0a1a1dd38..7712df759 100644 --- a/src/hooks/think-mode/switcher.ts +++ b/src/hooks/think-mode/switcher.ts @@ -16,6 +16,8 @@ * inconsistencies defensively while maintaining backwards compatibility. */ +import { normalizeModelID } from "../../shared" + /** * Extracts provider-specific prefix from model ID (if present). * Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/). @@ -36,24 +38,6 @@ function extractModelPrefix(modelID: string): { prefix: string; base: string } { } } -/** - * Normalizes model IDs to use consistent hyphen formatting. - * GitHub Copilot may use dots (claude-opus-4.6) but our maps use hyphens (claude-opus-4-6). - * This ensures lookups work regardless of format. - * - * @example - * normalizeModelID("claude-opus-4.6") // "claude-opus-4-6" - * normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro" - * normalizeModelID("gpt-5.2") // "gpt-5-2" - * normalizeModelID("vertex_ai/claude-opus-4.6") // "vertex_ai/claude-opus-4-6" - */ -function normalizeModelID(modelID: string): string { - // Replace dots with hyphens when followed by a digit - // This handles version numbers like 4.5 β†’ 4-5, 5.2 β†’ 5-2 - return modelID.replace(/\.(\d+)/g, "-$1") -} - - // Maps model IDs to their "high reasoning" variant (internal convention) // For OpenAI models, this signals that reasoning_effort should be set to "high" diff --git a/src/shared/AGENTS.md b/src/shared/AGENTS.md index 2cc20f9b6..be196d238 100644 --- a/src/shared/AGENTS.md +++ b/src/shared/AGENTS.md @@ -33,7 +33,7 @@ resolveModel(input) 4. System default: Ultimate fallback ``` -Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-name-matcher.ts` (fuzzy matching). +Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-availability.ts` (fuzzy matching). ## MIGRATION SYSTEM diff --git a/src/shared/fallback-model-availability.ts b/src/shared/fallback-model-availability.ts index 2162f422b..cae252177 100644 --- a/src/shared/fallback-model-availability.ts +++ b/src/shared/fallback-model-availability.ts @@ -1,6 +1,6 @@ import { readConnectedProvidersCache } from "./connected-providers-cache" import { log } from "./logger" -import { fuzzyMatchModel } from "./model-name-matcher" +import { fuzzyMatchModel } from "./model-availability" type FallbackEntry = { providers: string[]; model: string } diff --git a/src/shared/index.ts b/src/shared/index.ts index 09187602f..8615a7750 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -34,6 +34,7 @@ export * from "./system-directive" export * from "./agent-tool-restrictions" export * from "./model-requirements" export * from "./model-resolver" +export { normalizeModel, normalizeModelID } from "./model-normalization" export { normalizeFallbackModels } from "./model-resolver" export { resolveModelPipeline } from "./model-resolution-pipeline" export type { diff --git a/src/shared/model-name-matcher.ts b/src/shared/model-name-matcher.ts deleted file mode 100644 index bcdd3fb41..000000000 --- a/src/shared/model-name-matcher.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { log } from "./logger" - -function normalizeModelName(name: string): string { - return name - .toLowerCase() - .replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3") -} - -export function fuzzyMatchModel( - target: string, - available: Set, - providers?: string[], -): string | null { - log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers }) - - if (available.size === 0) { - log("[fuzzyMatchModel] empty available set") - return null - } - - const targetNormalized = normalizeModelName(target) - - let candidates = Array.from(available) - if (providers && providers.length > 0) { - const providerSet = new Set(providers) - candidates = candidates.filter((model) => { - const [provider] = model.split("/") - return providerSet.has(provider) - }) - log("[fuzzyMatchModel] filtered by providers", { - candidateCount: candidates.length, - candidates: candidates.slice(0, 10), - }) - } - - if (candidates.length === 0) { - log("[fuzzyMatchModel] no candidates after filter") - return null - } - - const matches = candidates.filter((model) => - normalizeModelName(model).includes(targetNormalized), - ) - - log("[fuzzyMatchModel] substring matches", { - targetNormalized, - matchCount: matches.length, - matches, - }) - - if (matches.length === 0) { - return null - } - - const exactMatch = matches.find( - (model) => normalizeModelName(model) === targetNormalized, - ) - if (exactMatch) { - log("[fuzzyMatchModel] exact match found", { exactMatch }) - return exactMatch - } - - const exactModelIdMatches = matches.filter((model) => { - const modelId = model.split("/").slice(1).join("/") - return normalizeModelName(modelId) === targetNormalized - }) - if (exactModelIdMatches.length > 0) { - const result = exactModelIdMatches.reduce((shortest, current) => - current.length < shortest.length ? current : shortest, - ) - log("[fuzzyMatchModel] exact model ID match found", { - result, - candidateCount: exactModelIdMatches.length, - }) - return result - } - - const result = matches.reduce((shortest, current) => - current.length < shortest.length ? current : shortest, - ) - log("[fuzzyMatchModel] shortest match", { result }) - return result -} diff --git a/src/shared/model-normalization.test.ts b/src/shared/model-normalization.test.ts new file mode 100644 index 000000000..2931690db --- /dev/null +++ b/src/shared/model-normalization.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { normalizeModel, normalizeModelID } from "./model-normalization" + +describe("normalizeModel", () => { + describe("#given undefined input", () => { + test("#when normalizeModel is called with undefined #then returns undefined", () => { + // given + const input = undefined + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) + + describe("#given empty string", () => { + test("#when normalizeModel is called with empty string #then returns undefined", () => { + // given + const input = "" + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) + + describe("#given whitespace-only string", () => { + test("#when normalizeModel is called with whitespace-only string #then returns undefined", () => { + // given + const input = " " + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) + + describe("#given valid model string", () => { + test("#when normalizeModel is called with valid model string #then returns same string", () => { + // given + const input = "claude-3-opus" + + // when + const result = normalizeModel(input) + + // then + expect(result).toBe("claude-3-opus") + }) + }) + + describe("#given string with leading and trailing spaces", () => { + test("#when normalizeModel is called with spaces #then returns trimmed string", () => { + // given + const input = " claude-3-opus " + + // when + const result = normalizeModel(input) + + // then + expect(result).toBe("claude-3-opus") + }) + }) + + describe("#given string with only spaces", () => { + test("#when normalizeModel is called with only spaces #then returns undefined", () => { + // given + const input = " " + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) +}) + +describe("normalizeModelID", () => { + describe("#given model with dots in version numbers", () => { + test("#when normalizeModelID is called with claude-3.5-sonnet #then returns claude-3-5-sonnet", () => { + // given + const input = "claude-3.5-sonnet" + + // when + const result = normalizeModelID(input) + + // then + expect(result).toBe("claude-3-5-sonnet") + }) + }) + + describe("#given model without dots", () => { + test("#when normalizeModelID is called with claude-opus #then returns unchanged", () => { + // given + const input = "claude-opus" + + // when + const result = normalizeModelID(input) + + // then + expect(result).toBe("claude-opus") + }) + }) + + describe("#given model with multiple dot-numbers", () => { + test("#when normalizeModelID is called with model.1.2 #then returns model-1-2", () => { + // given + const input = "model.1.2" + + // when + const result = normalizeModelID(input) + + // then + expect(result).toBe("model-1-2") + }) + }) +}) diff --git a/src/shared/model-normalization.ts b/src/shared/model-normalization.ts new file mode 100644 index 000000000..999ffb401 --- /dev/null +++ b/src/shared/model-normalization.ts @@ -0,0 +1,8 @@ +export function normalizeModel(model?: string): string | undefined { + const trimmed = model?.trim() + return trimmed || undefined +} + +export function normalizeModelID(modelID: string): string { + return modelID.replace(/\.(\d+)/g, "-$1") +} diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index 0d90b4f16..c51cad371 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -3,6 +3,7 @@ import * as connectedProvidersCache from "./connected-providers-cache" import { fuzzyMatchModel } from "./model-availability" import type { FallbackEntry } from "./model-requirements" import { transformModelForProvider } from "./provider-model-id-transform" +import { normalizeModel } from "./model-normalization" export type ModelResolutionRequest = { intent?: { @@ -35,10 +36,6 @@ export type ModelResolutionResult = { reason?: string } -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} export function resolveModelPipeline( request: ModelResolutionRequest, diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index e2e02fce3..977112cb1 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -1,4 +1,5 @@ import type { FallbackEntry } from "./model-requirements" +import { normalizeModel } from "./model-normalization" import { resolveModelPipeline } from "./model-resolution-pipeline" export type ModelResolutionInput = { @@ -29,10 +30,6 @@ export type ExtendedModelResolutionInput = { systemDefaultModel?: string } -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} export function resolveModel(input: ModelResolutionInput): string | undefined { return ( diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts index 188e3e374..e361ed642 100644 --- a/src/tools/delegate-task/model-selection.ts +++ b/src/tools/delegate-task/model-selection.ts @@ -1,11 +1,8 @@ import type { FallbackEntry } from "../../shared/model-requirements" +import { normalizeModel } from "../../shared/model-normalization" import { fuzzyMatchModel } from "../../shared/model-availability" import { transformModelForProvider } from "../../shared/provider-model-id-transform" -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} export function resolveModelForDelegateTask(input: { userModel?: string From 1c2caa09df6468efd9d17cf7dae6125781305d88 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 23:07:39 +0900 Subject: [PATCH 60/62] fix(preemptive-compaction): allow re-compaction after context grows and use model-specific limits compactedSessions permanently blocked re-compaction after first success, causing unbounded context growth (e.g. 500k on Kimi K2.5 with 256k limit). - Clear compactedSessions flag on new message.updated so compaction can re-trigger when context exceeds threshold again - Use modelContextLimitsCache for model-specific context limits instead of always falling back to 200k for non-Anthropic providers --- src/hooks/preemptive-compaction.test.ts | 153 ++++++++++++++++++++++++ src/hooks/preemptive-compaction.ts | 12 +- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index 279562aa6..e5d266c3d 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -414,4 +414,157 @@ describe("preemptive-compaction", () => { restoreTimeouts() } }) + + // #given first compaction succeeded and context grew again + // #when tool.execute.after runs after new high-token message + // #then should trigger compaction again (re-compaction) + it("should allow re-compaction when context grows after successful compaction", async () => { + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_recompact" + + // given - first compaction cycle + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + + // when - new message with high tokens (context grew after compaction) + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_2" }, + { title: "", output: "test", metadata: null } + ) + + // then - summarize should fire again + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(2) + }) + + // #given modelContextLimitsCache has model-specific limit (256k) + // #when tokens are above default 78% of 200k but below 78% of 256k + // #then should NOT trigger compaction + it("should use model-specific context limit from modelContextLimitsCache", async () => { + const modelContextLimitsCache = new Map() + modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144) + + const hook = createPreemptiveCompactionHook(ctx as never, {} as never, { + anthropicContext1MEnabled: false, + modelContextLimitsCache, + }) + const sessionID = "ses_kimi_limit" + + // 180k total tokens β€” above 78% of 200k (156k) but below 78% of 256k (204k) + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "opencode", + modelID: "kimi-k2.5-free", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).not.toHaveBeenCalled() + }) + + // #given modelContextLimitsCache has model-specific limit (256k) + // #when tokens exceed 78% of model-specific limit + // #then should trigger compaction + it("should trigger compaction at model-specific threshold", async () => { + const modelContextLimitsCache = new Map() + modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144) + + const hook = createPreemptiveCompactionHook(ctx as never, {} as never, { + anthropicContext1MEnabled: false, + modelContextLimitsCache, + }) + const sessionID = "ses_kimi_trigger" + + // 210k total β€” above 78% of 256k (β‰ˆ204k) + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "opencode", + modelID: "kimi-k2.5-free", + finish: true, + tokens: { + input: 200000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalled() + }) }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index d6c9bf130..d93211fd1 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -7,6 +7,7 @@ const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 type ModelCacheStateLike = { anthropicContext1MEnabled: boolean + modelContextLimitsCache?: Map } function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number { @@ -91,10 +92,12 @@ export function createPreemptiveCompactionHook( const cached = tokenCache.get(sessionID) if (!cached) return - const actualLimit = - isAnthropicProvider(cached.providerID) - ? getAnthropicActualLimit(modelCacheState) - : DEFAULT_ACTUAL_LIMIT + const modelSpecificLimit = !isAnthropicProvider(cached.providerID) + ? modelCacheState?.modelContextLimitsCache?.get(`${cached.providerID}/${cached.modelID}`) + : undefined + const actualLimit = isAnthropicProvider(cached.providerID) + ? getAnthropicActualLimit(modelCacheState) + : modelSpecificLimit ?? DEFAULT_ACTUAL_LIMIT const lastTokens = cached.tokens const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0) @@ -164,6 +167,7 @@ export function createPreemptiveCompactionHook( modelID: info.modelID ?? "", tokens: info.tokens, }) + compactedSessions.delete(info.sessionID) } } From 3d4269dcf9c238dab859a4713fe8ae3a20900f0c Mon Sep 17 00:00:00 2001 From: sisyphus-dev-ai Date: Mon, 2 Mar 2026 14:40:25 +0000 Subject: [PATCH 61/62] chore: changes by sisyphus-dev-ai --- bun.lock | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 1c5d84e94..c67b9095a 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@clack/prompts": "^0.11.0", - "@code-yeongyu/comment-checker": "^0.6.1", + "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", "@opencode-ai/plugin": "^1.1.19", "@opencode-ai/sdk": "^1.1.19", @@ -29,13 +29,17 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.8.5", - "oh-my-opencode-darwin-x64": "3.8.5", - "oh-my-opencode-linux-arm64": "3.8.5", - "oh-my-opencode-linux-arm64-musl": "3.8.5", - "oh-my-opencode-linux-x64": "3.8.5", - "oh-my-opencode-linux-x64-musl": "3.8.5", - "oh-my-opencode-windows-x64": "3.8.5", + "oh-my-opencode-darwin-arm64": "3.10.0", + "oh-my-opencode-darwin-x64": "3.10.0", + "oh-my-opencode-darwin-x64-baseline": "3.10.0", + "oh-my-opencode-linux-arm64": "3.10.0", + "oh-my-opencode-linux-arm64-musl": "3.10.0", + "oh-my-opencode-linux-x64": "3.10.0", + "oh-my-opencode-linux-x64-baseline": "3.10.0", + "oh-my-opencode-linux-x64-musl": "3.10.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.10.0", + "oh-my-opencode-windows-x64": "3.10.0", + "oh-my-opencode-windows-x64-baseline": "3.10.0", }, }, }, @@ -85,7 +89,7 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="], + "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.7.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-AOic1jPHY3CpNraOuO87YZHO3uRzm9eLd0wyYYN89/76Ugk2TfdUYJ6El/Oe8fzOnHKiOF0IfBeWRo0IUjrHHg=="], "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -231,19 +235,27 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.10.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KQ1Nva4eU03WIaQI8BiEgizYJAeddUIaC8dmks0Ug/2EkH6VyNj41+shI58HFGN9Jlg9Fd6MxpOW92S3JUHjOw=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.10.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-PydZ6wKyLZzikSZA3Q89zKZwFyg0Ouqd/S6zDsf1zzpUWT1t5EcpBtYFwuscD7L4hdkIEFm8wxnnBkz5i6BEiA=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="], + "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.10.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-yOaVd0E1qspT2xP/BMJaJ/rpFTwkOh9U/SAk6uOuxHld6dZGI9e2Oq8F3pSD16xHnnpaz4VzadtT6HkvPdtBYg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.10.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pLzcPMuzBb1tpVgqMilv7QdsE2xTMLCWT3b807mzjt0302fZTfm6emwymCG25RamHdq7+mI2B0rN7hjvbymFog=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.10.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ca61zr+X8q0ipO2x72qU+4R6Dsr168OM9aXI6xDHbrr0l3XZlRO8xuwQidch1vE5QRv2/IJT10KjAFInCERDug=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m0Ys8Vnl8jUNRE5/aIseNOF1H57/W77xh3vkyBVfnjzHwQdEUWZz3IdoHaEWIFgIP2+fsNXRHqpx7Pbtuhxo6Q=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="], + "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-a6OhfqMXhOTq1On8YHRRlVsNtMx84kgNAnStk/sY1Dw0kXU68QK4tWXVF+wNdiRG3egeM2SvjhJ5RhWlr3CCNQ=="], + + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-lZkoEWwmrlVoZKewHNslUmQ2D6eWi1YqsoZMTd3qRj8V4XI6TDZHxg86hw4oxZ/EnKO4un+r83tb09JAAb1nNQ=="], + + "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UqArUpatMuen8+hZhMSbScaSmJlcwkEtf/IzDN1iYO0CttvhyYMUmm3el/1gWTAcaGNDFNkGmTli5WNYhnm2lA=="], + + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.10.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-BivOu1+Yty9N6VSmNzmxROZqjQKu3ImWjooKZDfczvYLDQmZV104QcOKV6bmdOCpHrqQ7cvdbygmeiJeRoYShg=="], + + "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.10.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-BBv+dNPuh9LEuqXUJLXNsvi3vL30zS1qcJuzlq/s8rYHry+VvEVXCRcMm5Vo0CVna8bUZf5U8MDkGDHOAiTeEw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], From 33d39597aeef29ea1414d85a76c8ef04f414dafa Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 2 Mar 2026 23:36:09 +0900 Subject: [PATCH 62/62] docs(agents): regenerate AGENTS.md hierarchy with updated metrics and model configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1208β†’1243 TS files (+35), 143kβ†’155k LOC (+12k) - Update all agent models: Sisyphus, Hephaestus, Oracle, Librarian, Atlas, Metis, Momus - Add 6 new hook directories (39β†’45 dirs): beast-mode-system, hashline-edit-diff-enhancer, anthropic-image-context, task-reminder, compaction-todo-preserver, runtime-fallback - Update category models: visual-engineering/artistry gemini-3-proβ†’gemini-3.1-pro - Add 2 config schema files: fallback-models.ts, runtime-fallback.ts - Timestamp: 2026-03-02 | Commit: 1c2caa09 πŸ€– Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- AGENTS.md | 14 ++--- src/AGENTS.md | 2 +- src/agents/AGENTS.md | 22 +++---- src/cli/AGENTS.md | 2 +- src/cli/config-manager/AGENTS.md | 2 +- src/cli/run/AGENTS.md | 2 +- src/config/AGENTS.md | 6 +- src/features/AGENTS.md | 2 +- src/features/background-agent/AGENTS.md | 4 +- src/features/claude-tasks/AGENTS.md | 2 +- src/features/mcp-oauth/AGENTS.md | 2 +- src/features/opencode-skill-loader/AGENTS.md | 2 +- src/features/tmux-subagent/AGENTS.md | 2 +- src/hooks/AGENTS.md | 62 +++++++++++-------- .../AGENTS.md | 2 +- src/hooks/atlas/AGENTS.md | 2 +- src/hooks/claude-code-hooks/AGENTS.md | 2 +- src/hooks/keyword-detector/AGENTS.md | 2 +- src/hooks/ralph-loop/AGENTS.md | 2 +- src/hooks/rules-injector/AGENTS.md | 2 +- src/hooks/session-recovery/AGENTS.md | 2 +- .../todo-continuation-enforcer/AGENTS.md | 2 +- src/mcp/AGENTS.md | 2 +- src/plugin-handlers/AGENTS.md | 2 +- src/plugin/AGENTS.md | 2 +- src/shared/AGENTS.md | 4 +- src/tools/AGENTS.md | 6 +- src/tools/background-task/AGENTS.md | 2 +- src/tools/call-omo-agent/AGENTS.md | 2 +- src/tools/delegate-task/AGENTS.md | 2 +- src/tools/hashline-edit/AGENTS.md | 2 +- src/tools/lsp/AGENTS.md | 2 +- 32 files changed, 90 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0c31f2fe..abf615894 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # oh-my-opencode β€” OpenCode Plugin -**Generated:** 2026-02-24 | **Commit:** fcb90d92 | **Branch:** dev +**Generated:** 2026-03-02 | **Commit:** 1c2caa09 | **Branch:** dev ## OVERVIEW -OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC. +OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1243 TypeScript files, 155k LOC. ## STRUCTURE @@ -14,16 +14,16 @@ oh-my-opencode/ β”‚ β”œβ”€β”€ index.ts # Plugin entry: loadConfig β†’ createManagers β†’ createTools β†’ createHooks β†’ createPluginInterface β”‚ β”œβ”€β”€ plugin-config.ts # JSONC multi-level config: user β†’ project β†’ defaults (Zod v4) β”‚ β”œβ”€β”€ agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) -|Β `hooks/`Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β # 46 hooks across 39 directories + 6 standalone files +β”‚ β”œβ”€β”€ hooks/ # 46 hooks across 45 directories + 11 standalone files β”‚ β”œβ”€β”€ tools/ # 26 tools across 15 directories β”‚ β”œβ”€β”€ features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.) -β”‚ β”œβ”€β”€ shared/ # 100+ utility files in 13 categories -β”‚ β”œβ”€β”€ config/ # Zod v4 schema system (22+ files) +β”‚ β”œβ”€β”€ shared/ # 95+ utility files in 13 categories +β”‚ β”œβ”€β”€ config/ # Zod v4 schema system (24 files) β”‚ β”œβ”€β”€ cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) β”‚ β”œβ”€β”€ mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) β”‚ β”œβ”€β”€ plugin/ # 8 OpenCode hook handlers + 46 hook composition β”‚ └── plugin-handlers/ # 6-phase config loading pipeline -β”œβ”€β”€ packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries +β”œβ”€β”€ packages/ # Monorepo: cli-runner, 12 platform binaries └── local-ignore/ # Dev-only test fixtures ``` @@ -123,7 +123,7 @@ bunx oh-my-opencode run # Non-interactive session |----------|---------|---------| | ci.yml | push/PR | Tests (split: mock-heavy isolated + batch), typecheck, build, schema auto-commit | | publish.yml | manual | Version bump, npm publish, platform binaries, GitHub release, merge to dev | -| publish-platform.yml | called | 11 platform binaries via bun compile (darwin/linux/windows) | +| publish-platform.yml | called | 12 platform binaries via bun compile (darwin/linux/windows) | | sisyphus-agent.yml | @mention | AI agent handles issues/PRs | ## NOTES diff --git a/src/AGENTS.md b/src/AGENTS.md index 197af269c..b224e8be5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,6 +1,6 @@ # src/ β€” Plugin Source -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md index 289a0fca7..5ce16f271 100644 --- a/src/agents/AGENTS.md +++ b/src/agents/AGENTS.md @@ -1,6 +1,6 @@ # src/agents/ β€” 11 Agent Definitions -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW @@ -10,16 +10,16 @@ Agent factories following `createXXXAgent(model) β†’ AgentConfig` pattern. Each | Agent | Model | Temp | Mode | Fallback Chain | Purpose | |-------|-------|------|------|----------------|---------| -| **Sisyphus** | claude-opus-4-6 | 0.1 | primary | kimi-k2.5 β†’ glm-4.7 β†’ gemini-3-pro | Main orchestrator, plans + delegates | -| **Hephaestus** | gpt-5.3-codex | 0.1 | primary | NONE (required) | Autonomous deep worker | -| **Oracle** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 β†’ gemini-3-pro | Read-only consultation | -| **Librarian** | glm-4.7 | 0.1 | subagent | big-pickle β†’ claude-sonnet-4-6 | External docs/code search | -| **Explore** | grok-code-fast-1 | 0.1 | subagent | claude-haiku-4-5 β†’ gpt-5-nano | Contextual grep | -| **Multimodal-Looker** | gemini-3-flash | 0.1 | subagent | gpt-5.2 β†’ glm-4.6v β†’ ... (6 deep) | PDF/image analysis | -| **Metis** | claude-opus-4-6 | **0.3** | subagent | kimi-k2.5 β†’ gpt-5.2 β†’ gemini-3-pro | Pre-planning consultant | -| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 β†’ gemini-3-pro | Plan reviewer | -| **Atlas** | claude-sonnet-4-6 | 0.1 | primary | kimi-k2.5 β†’ gpt-5.2 β†’ gemini-3-pro | Todo-list orchestrator | -| **Prometheus** | claude-opus-4-6 | 0.1 | β€” | kimi-k2.5 β†’ gpt-5.2 β†’ gemini-3-pro | Strategic planner (internal) | +| **Sisyphus** | claude-opus-4-6 | 0.1 | all | kimi-k2.5 β†’ glm-5 β†’ big-pickle | Main orchestrator, plans + delegates | +| **Hephaestus** | gpt-5.3-codex | 0.1 | all | gpt-5.2 (copilot) | Autonomous deep worker | +| **Oracle** | gpt-5.2 | 0.1 | subagent | gemini-3.1-pro β†’ claude-opus-4-6 | Read-only consultation | +| **Librarian** | kimi-k2.5 | 0.1 | subagent | gemini-3-flash β†’ gpt-5.2 β†’ glm-4.6v | External docs/code search | +| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.5 β†’ claude-haiku-4-5 β†’ gpt-5-nano | Contextual grep | +| **Multimodal-Looker** | gemini-3-flash | 0.1 | subagent | minimax-m2.5 β†’ big-pickle | PDF/image analysis | +| **Metis** | claude-opus-4-6 | **0.3** | subagent | gpt-5.2 β†’ kimi-k2.5 β†’ gemini-3.1-pro | Pre-planning consultant | +| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 β†’ gemini-3.1-pro | Plan reviewer | +| **Atlas** | kimi-k2.5 | 0.1 | primary | claude-sonnet-4-6 β†’ gpt-5.2 | Todo-list orchestrator | +| **Prometheus** | claude-opus-4-6 | 0.1 | β€” | kimi-k2.5 β†’ gpt-5.2 β†’ gemini-3.1-pro | Strategic planner (internal) | | **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor | ## TOOL RESTRICTIONS diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 1cdb7fe5d..01abe527a 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -1,6 +1,6 @@ # src/cli/ β€” CLI: install, run, doctor, mcp-oauth -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/cli/config-manager/AGENTS.md b/src/cli/config-manager/AGENTS.md index 45e3d2d14..37f8c80b6 100644 --- a/src/cli/config-manager/AGENTS.md +++ b/src/cli/config-manager/AGENTS.md @@ -1,6 +1,6 @@ # src/cli/config-manager/ β€” CLI Installation Utilities -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/cli/run/AGENTS.md b/src/cli/run/AGENTS.md index 4f9fb2ec1..c81764a04 100644 --- a/src/cli/run/AGENTS.md +++ b/src/cli/run/AGENTS.md @@ -1,6 +1,6 @@ # src/cli/run/ β€” Non-Interactive Session Launcher -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 0ec14879c..9b443d3c6 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -1,10 +1,10 @@ # src/config/ β€” Zod v4 Schema System -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW -22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional β€” omitted fields use plugin defaults. +24 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional β€” omitted fields use plugin defaults. ## SCHEMA TREE @@ -29,6 +29,8 @@ config/schema/ β”œβ”€β”€ git-master.ts # commit_footer: boolean | string β”œβ”€β”€ browser-automation.ts # provider: playwright | agent-browser | playwright-cli β”œβ”€β”€ background-task.ts # Concurrency limits per model/provider +β”œβ”€β”€ fallback-models.ts # FallbackModelsConfigSchema +β”œβ”€β”€ runtime-fallback.ts # RuntimeFallbackConfigSchema β”œβ”€β”€ babysitting.ts # Unstable agent monitoring β”œβ”€β”€ dynamic-context-pruning.ts # Context pruning settings β”œβ”€β”€ start-work.ts # StartWorkConfigSchema (auto_commit) diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index cec212e57..9a000826c 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -1,6 +1,6 @@ # src/features/ β€” 19 Feature Modules -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/background-agent/AGENTS.md b/src/features/background-agent/AGENTS.md index 0b4b18ec0..615bb8e25 100644 --- a/src/features/background-agent/AGENTS.md +++ b/src/features/background-agent/AGENTS.md @@ -1,10 +1,10 @@ # src/features/background-agent/ β€” Core Orchestration Engine -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW -39 files (~10k LOC). Manages async task lifecycle: launch β†’ queue β†’ run β†’ poll β†’ complete/error. Concurrency limited per model/provider (default 5). Central to multi-agent orchestration. +30 files (~10k LOC). Manages async task lifecycle: launch β†’ queue β†’ run β†’ poll β†’ complete/error. Concurrency limited per model/provider (default 5). Central to multi-agent orchestration. ## TASK LIFECYCLE diff --git a/src/features/claude-tasks/AGENTS.md b/src/features/claude-tasks/AGENTS.md index 25d00ae04..9b444252f 100644 --- a/src/features/claude-tasks/AGENTS.md +++ b/src/features/claude-tasks/AGENTS.md @@ -1,6 +1,6 @@ # src/features/claude-tasks/ β€” Task Schema + Storage -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/mcp-oauth/AGENTS.md b/src/features/mcp-oauth/AGENTS.md index 97f017c29..237c62e12 100644 --- a/src/features/mcp-oauth/AGENTS.md +++ b/src/features/mcp-oauth/AGENTS.md @@ -1,6 +1,6 @@ # src/features/mcp-oauth/ β€” OAuth 2.0 + PKCE + DCR for MCP Servers -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/opencode-skill-loader/AGENTS.md b/src/features/opencode-skill-loader/AGENTS.md index 5c617673e..447366f0d 100644 --- a/src/features/opencode-skill-loader/AGENTS.md +++ b/src/features/opencode-skill-loader/AGENTS.md @@ -1,6 +1,6 @@ # src/features/opencode-skill-loader/ β€” 4-Scope Skill Discovery -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/tmux-subagent/AGENTS.md b/src/features/tmux-subagent/AGENTS.md index 69e8ccfa2..73119c9f2 100644 --- a/src/features/tmux-subagent/AGENTS.md +++ b/src/features/tmux-subagent/AGENTS.md @@ -1,6 +1,6 @@ # src/features/tmux-subagent/ β€” Tmux Pane Management -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 6e22ff9b4..277500af2 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -1,10 +1,10 @@ # src/hooks/ β€” 46 Lifecycle Hooks -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW -46 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) β†’ HookFunction` factory pattern. +46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) β†’ HookFunction` factory pattern. ## HOOK TIERS @@ -14,38 +14,48 @@ hooks/ β”œβ”€β”€ atlas/ # Main orchestration (757 lines) β”œβ”€β”€ anthropic-context-window-limit-recovery/ # Auto-summarize -β”œβ”€β”€ todo-continuation-enforcer.ts # Force TODO completion -β”œβ”€β”€ ralph-loop/ # Self-referential dev loop -β”œβ”€β”€ claude-code-hooks/ # settings.json compat layer - see AGENTS.md -β”œβ”€β”€ comment-checker/ # Prevents AI slop +β”œβ”€β”€ anthropic-effort/ # Reasoning effort level adjustment +β”œβ”€β”€ anthropic-image-context/ # Image context handling for Anthropic β”œβ”€β”€ auto-slash-command/ # Detects /command patterns -β”œβ”€β”€ rules-injector/ # Conditional rules +β”œβ”€β”€ auto-update-checker/ # Plugin update check +β”œβ”€β”€ background-notification/ # OS notification +β”œβ”€β”€ beast-mode-system/ # Beast mode system prompt injection +β”œβ”€β”€ category-skill-reminder/ # Reminds of category skills +β”œβ”€β”€ claude-code-hooks/ # settings.json compat layer +β”œβ”€β”€ comment-checker/ # Prevents AI slop +β”œβ”€β”€ compaction-context-injector/ # Injects context on compaction +β”œβ”€β”€ compaction-todo-preserver/ # Preserves todos through compaction +β”œβ”€β”€ delegate-task-retry/ # Retries failed delegations β”œβ”€β”€ directory-agents-injector/ # Auto-injects AGENTS.md β”œβ”€β”€ directory-readme-injector/ # Auto-injects README.md β”œβ”€β”€ edit-error-recovery/ # Recovers from failures -β”œβ”€β”€ thinking-block-validator/ # Ensures valid -β”œβ”€β”€ context-window-monitor.ts # Reminds of headroom -β”œβ”€β”€ session-recovery/ # Auto-recovers from crashes -β”œβ”€β”€ think-mode/ # Dynamic thinking budget -β”œβ”€β”€ keyword-detector/ # ultrawork/search/analyze modes -β”œβ”€β”€ background-notification/ # OS notification -β”œβ”€β”€ prometheus-md-only/ # Planner read-only mode -β”œβ”€β”€ agent-usage-reminder/ # Specialized agent hints -β”œβ”€β”€ auto-update-checker/ # Plugin update check -β”œβ”€β”€ tool-output-truncator.ts # Prevents context bloat -β”œβ”€β”€ compaction-context-injector/ # Injects context on compaction -β”œβ”€β”€ delegate-task-retry/ # Retries failed delegations +β”œβ”€β”€ hashline-edit-diff-enhancer/ # Enhanced diff output for hashline edits +β”œβ”€β”€ hashline-read-enhancer/ # Adds LINE#ID hashes to Read output β”œβ”€β”€ interactive-bash-session/ # Tmux session management +β”œβ”€β”€ json-error-recovery/ # JSON parse error correction +β”œβ”€β”€ keyword-detector/ # ultrawork/search/analyze modes +β”œβ”€β”€ model-fallback/ # Provider-level model fallback +β”œβ”€β”€ no-hephaestus-non-gpt/ # Block Hephaestus from non-GPT +β”œβ”€β”€ no-sisyphus-gpt/ # Block Sisyphus from GPT β”œβ”€β”€ non-interactive-env/ # Non-TTY environment handling -β”œβ”€β”€ start-work/ # Sisyphus work session starter -β”œβ”€β”€ task-resume-info/ # Resume info for cancelled tasks +β”œβ”€β”€ prometheus-md-only/ # Planner read-only mode β”œβ”€β”€ question-label-truncator/ # Auto-truncates question labels -β”œβ”€β”€ category-skill-reminder/ # Reminds of category skills -β”œβ”€β”€ empty-task-response-detector.ts # Detects empty responses -β”œβ”€β”€ sisyphus-junior-notepad/ # Sisyphus Junior notepad -β”œβ”€β”€ stop-continuation-guard/ # Guards stop continuation -β”œβ”€β”€ subagent-question-blocker/ # Blocks subagent questions +β”œβ”€β”€ ralph-loop/ # Self-referential dev loop +β”œβ”€β”€ read-image-resizer/ # Resize images for context efficiency +β”œβ”€β”€ rules-injector/ # Conditional rules β”œβ”€β”€ runtime-fallback/ # Auto-switch models on API errors +β”œβ”€β”€ session-recovery/ # Auto-recovers from crashes +β”œβ”€β”€ sisyphus-junior-notepad/ # Sisyphus Junior notepad +β”œβ”€β”€ start-work/ # Sisyphus work session starter +β”œβ”€β”€ stop-continuation-guard/ # Guards stop continuation +β”œβ”€β”€ task-reminder/ # Task system usage reminders +β”œβ”€β”€ task-resume-info/ # Resume info for cancelled tasks +β”œβ”€β”€ tasks-todowrite-disabler/ # Disable TodoWrite when task system active +β”œβ”€β”€ think-mode/ # Dynamic thinking budget +β”œβ”€β”€ thinking-block-validator/ # Ensures valid +β”œβ”€β”€ todo-continuation-enforcer/ # Force TODO completion +β”œβ”€β”€ unstable-agent-babysitter/ # Monitor unstable agent behavior +β”œβ”€β”€ write-existing-file-guard/ # Require Read before Write └── index.ts # Hook aggregation + registration ``` diff --git a/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md b/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md index 0234760e4..5da2ecf8f 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md +++ b/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/anthropic-context-window-limit-recovery/ β€” Multi-Strategy Context Recovery -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/atlas/AGENTS.md b/src/hooks/atlas/AGENTS.md index e0c435e2d..63c9cc223 100644 --- a/src/hooks/atlas/AGENTS.md +++ b/src/hooks/atlas/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/atlas/ β€” Master Boulder Orchestrator -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/claude-code-hooks/AGENTS.md b/src/hooks/claude-code-hooks/AGENTS.md index 03b88a73c..f9dd368bd 100644 --- a/src/hooks/claude-code-hooks/AGENTS.md +++ b/src/hooks/claude-code-hooks/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/claude-code-hooks/ β€” Claude Code Compatibility -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/keyword-detector/AGENTS.md b/src/hooks/keyword-detector/AGENTS.md index 34f081182..94b374b2b 100644 --- a/src/hooks/keyword-detector/AGENTS.md +++ b/src/hooks/keyword-detector/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/keyword-detector/ β€” Mode Keyword Injection -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/ralph-loop/AGENTS.md b/src/hooks/ralph-loop/AGENTS.md index 7e35f4371..4f94da682 100644 --- a/src/hooks/ralph-loop/AGENTS.md +++ b/src/hooks/ralph-loop/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/ralph-loop/ β€” Self-Referential Dev Loop -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/rules-injector/AGENTS.md b/src/hooks/rules-injector/AGENTS.md index c43c66b3e..f3767e222 100644 --- a/src/hooks/rules-injector/AGENTS.md +++ b/src/hooks/rules-injector/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/rules-injector/ β€” Conditional Rules Injection -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/session-recovery/AGENTS.md b/src/hooks/session-recovery/AGENTS.md index 3959b41c7..ecd43ae61 100644 --- a/src/hooks/session-recovery/AGENTS.md +++ b/src/hooks/session-recovery/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/session-recovery/ β€” Auto Session Error Recovery -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/todo-continuation-enforcer/AGENTS.md b/src/hooks/todo-continuation-enforcer/AGENTS.md index 6f0166b66..132a16bfd 100644 --- a/src/hooks/todo-continuation-enforcer/AGENTS.md +++ b/src/hooks/todo-continuation-enforcer/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/todo-continuation-enforcer/ β€” Boulder Continuation Mechanism -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index cb2304806..9c728114e 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -1,6 +1,6 @@ # src/mcp/ β€” 3 Built-in Remote MCPs -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md index 844242bd0..04265d5df 100644 --- a/src/plugin-handlers/AGENTS.md +++ b/src/plugin-handlers/AGENTS.md @@ -1,6 +1,6 @@ # src/plugin-handlers/ β€” 6-Phase Config Loading Pipeline -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/plugin/AGENTS.md b/src/plugin/AGENTS.md index a3aa2024a..d751a2fd1 100644 --- a/src/plugin/AGENTS.md +++ b/src/plugin/AGENTS.md @@ -1,6 +1,6 @@ # src/plugin/ β€” 8 OpenCode Hook Handlers + Hook Composition -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/shared/AGENTS.md b/src/shared/AGENTS.md index be196d238..18bd85fee 100644 --- a/src/shared/AGENTS.md +++ b/src/shared/AGENTS.md @@ -1,6 +1,6 @@ -# src/shared/ β€” 101 Utility Files in 13 Categories +# src/shared/ β€” 95+ Utility Files in 13 Categories -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 2604ad9ec..48c992383 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/ β€” 26 Tools Across 15 Directories -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW @@ -91,10 +91,10 @@ | Category | Model | Domain | |----------|-------|--------| -| visual-engineering | gemini-3-pro | Frontend, UI/UX | +| visual-engineering | gemini-3.1-pro high | Frontend, UI/UX | | ultrabrain | gpt-5.3-codex xhigh | Hard logic | | deep | gpt-5.3-codex medium | Autonomous problem-solving | -| artistry | gemini-3-pro high | Creative approaches | +| artistry | gemini-3.1-pro high | Creative approaches | | quick | claude-haiku-4-5 | Trivial tasks | | unspecified-low | claude-sonnet-4-6 | Moderate effort | | unspecified-high | claude-opus-4-6 max | High effort | diff --git a/src/tools/background-task/AGENTS.md b/src/tools/background-task/AGENTS.md index bdf486fe3..32285831b 100644 --- a/src/tools/background-task/AGENTS.md +++ b/src/tools/background-task/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/background-task/ β€” Background Task Tool Wrappers -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/call-omo-agent/AGENTS.md b/src/tools/call-omo-agent/AGENTS.md index adbe35fe4..1b551f304 100644 --- a/src/tools/call-omo-agent/AGENTS.md +++ b/src/tools/call-omo-agent/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/call-omo-agent/ β€” Direct Agent Invocation Tool -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/delegate-task/AGENTS.md b/src/tools/delegate-task/AGENTS.md index 8f5334260..8adeedfa5 100644 --- a/src/tools/delegate-task/AGENTS.md +++ b/src/tools/delegate-task/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/delegate-task/ β€” Task Delegation Engine -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/hashline-edit/AGENTS.md b/src/tools/hashline-edit/AGENTS.md index 3054b21d2..0eb4a8233 100644 --- a/src/tools/hashline-edit/AGENTS.md +++ b/src/tools/hashline-edit/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/hashline-edit/ β€” Hash-Anchored File Edit Tool -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/lsp/AGENTS.md b/src/tools/lsp/AGENTS.md index 13968fff6..ff43d92e0 100644 --- a/src/tools/lsp/AGENTS.md +++ b/src/tools/lsp/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/lsp/ β€” LSP Tool Implementations -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW