diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5d2330..07dcacf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,14 +19,11 @@ jobs: with: bun-version: latest - - name: Install dependencies - run: bun install - - - name: Lint - run: bun run lint + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" - - name: Build - run: bun run build - name: Prevent duplicate publish run: | if npm view opencode-notification@$(node -p "require('./package.json').version") version; then @@ -34,10 +31,11 @@ jobs: exit 1 fi - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" + - name: Install dependencies + run: bun install + + - name: Lint + run: bun run lint - name: Publish to npm run: npm publish --access public diff --git a/bun.lock b/bun.lock index 6129799..32a9b6b 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "opencode-notification", "dependencies": { "@opencode-ai/plugin": "^1.2.16", + "zod": "^4.3.6", }, "devDependencies": { "@types/bun": "latest", @@ -115,6 +116,8 @@ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } } diff --git a/index.ts b/index.ts index 55ea84b..b66783b 100644 --- a/index.ts +++ b/index.ts @@ -1,9 +1,25 @@ import path from "node:path"; import type { Plugin } from "@opencode-ai/plugin"; import { createNotificationScheduler } from "@/notification-scheduler"; +import { loadConfig, ConfigError } from "@/config/loader"; +import { createResolvedConfig } from "@/config/resolver"; export const SimpleNotificationPlugin: Plugin = async ({ client }) => { - const scheduler = createNotificationScheduler(); + const config = await loadConfig().catch(async (error) => { + const message = + error instanceof ConfigError + ? `Notification plugin config error: ${error.message}` + : `Notification plugin failed to load: ${error instanceof Error ? error.message : String(error)}`; + + await client.tui.showToast({ + body: { message, variant: "error" }, + }); + + throw error; + }); + + const resolvedConfig = createResolvedConfig(config); + const scheduler = createNotificationScheduler(resolvedConfig); // Tracks sessions where assistant has responded since last user message const activeSessions = new Set(); @@ -22,7 +38,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { .get({ path: { id: sessionId } }) .then((details) => details.data?.title) .catch(() => undefined); - scheduler.schedule(sessionId, "Response ready", title ?? sessionId); + scheduler.schedule(sessionId, "Response ready", title ?? sessionId, "session.idle"); } activeSessions.delete(sessionId); break; @@ -37,7 +53,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { .catch(() => undefined) : (event.properties.error?.data.message as string); if (sessionId) { - scheduler.schedule(sessionId, "Session error", message ?? sessionId); + scheduler.schedule(sessionId, "Session error", message ?? sessionId, "session.error"); } break; } @@ -58,6 +74,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { sessionId, "Permission Asked", `${session?.title} in ${projectName} needs permission`, + "permission.asked", ); break; } @@ -78,6 +95,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { sessionId, "Question", `${session?.title} in ${projectName} has a question`, + "question.asked", ); break; } @@ -95,8 +113,6 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { case "message.updated": { const info = event.properties.info; if (info.role === "user") { - // Only cancel for real user messages, not automatic system messages - // System messages have 'agent' or 'model' fields const infoAny = info; const isAutomaticMessage = infoAny.agent || infoAny.model; if (!isAutomaticMessage) { @@ -119,7 +135,6 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { case "tui.prompt.append": case "tui.command.execute": - // No sessionID in these events, can't cancel reliably break; } }, diff --git a/package.json b/package.json index 5acf39e..3e5f642 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "url": "git+https://github.com/IdrisGit/opencode-notification.git" }, "files": [ - "dist" + "dist", + "schema" ], "type": "module", "main": "dist/index.js", @@ -28,14 +29,17 @@ } }, "scripts": { - "build": "bun run clean && bun build index.ts --outdir dist --target bun --format esm && bunx tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepack": "bun run build", + "build": "bun run clean && bun run generate-schema && bun build index.ts --outdir dist --target bun --format esm && bunx tsc -p tsconfig.build.json --emitDeclarationOnly", + "generate-schema": "bun scripts/generate-schema.ts", "clean": "rm -rf dist", "lint": "oxlint .", "fmt": "oxfmt", "fmt:check": "oxfmt --check" }, "dependencies": { - "@opencode-ai/plugin": "^1.2.16" + "@opencode-ai/plugin": "^1.2.16", + "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "latest", diff --git a/schema/oc-notification.json b/schema/oc-notification.json new file mode 100644 index 0000000..e2ec10f --- /dev/null +++ b/schema/oc-notification.json @@ -0,0 +1,93 @@ +{ + "$id": "https://unpkg.com/opencode-notification@0.0.3/schema/oc-notification.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "delay": { + "default": 15, + "description": "Default notification delay in seconds", + "type": "number" + }, + "enabled": { + "default": true, + "description": "Master switch to enable/disable all notifications", + "type": "boolean" + }, + "response_ready": { + "default": { + "enabled": true + }, + "description": "Notification when AI response is ready", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "error": { + "default": { + "enabled": true + }, + "description": "Notification on session error", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "permission_asked": { + "default": { + "enabled": true + }, + "description": "Notification when permission is requested", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "question_asked": { + "default": { + "enabled": true + }, + "description": "Notification when a question is asked", + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Enable notifications for this event type", + "type": "boolean" + }, + "delay": { + "description": "Optional override for notification delay in seconds", + "type": "number" + } + } + }, + "$schema": { + "type": "string", + "description": "JSON Schema URL for editor autocomplete and validation" + } + } +} diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts new file mode 100644 index 0000000..4dc2820 --- /dev/null +++ b/scripts/generate-schema.ts @@ -0,0 +1,32 @@ +import * as z from "zod"; +import { CONFIG_FILE_NAME } from "@/config/constants"; +import { ConfigSchema } from "@/config/schema"; + +// Read package.json to get version for schema $id +const packageJsonPath = `${import.meta.dir}/../package.json`; +const packageJson = await Bun.file(packageJsonPath).json(); +const version = packageJson.version; + +const jsonSchema = z.toJSONSchema(ConfigSchema, { + io: "input", + target: "draft-2020-12", + unrepresentable: "throw", +}); +const rootSchema = jsonSchema as z.core.JSONSchema.ObjectSchema; +rootSchema.properties ??= {}; +rootSchema.properties.$schema = { + type: "string", + description: "JSON Schema URL for editor autocomplete and validation", +}; + +// Add $id with versioned URL for npm/unpkg CDN +// This allows users to reference specific schema versions +const schemaWithId = { + $id: `https://unpkg.com/opencode-notification@${version}/schema/${CONFIG_FILE_NAME}`, + ...jsonSchema, +}; + +const outputPath = `${import.meta.dir}/../schema/${CONFIG_FILE_NAME}`; +await Bun.write(outputPath, JSON.stringify(schemaWithId, null, 2)); + +console.log(`Generated JSON schema: schema/${CONFIG_FILE_NAME} (version ${version})`); diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 0000000..e1adeb1 --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1 @@ +export const CONFIG_FILE_NAME = "oc-notification.json"; diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000..d44062d --- /dev/null +++ b/src/config/loader.ts @@ -0,0 +1,132 @@ +import { homedir } from "node:os"; +import { ConfigSchema, type Config } from "@/config/schema"; +import { CONFIG_FILE_NAME } from "@/config/constants"; + +/** + * Custom error class for configuration-related errors. + */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +/** + * Recursively merges objects. Arrays are replaced, not merged. + * Null/undefined values in source do not overwrite existing values. + */ +function deepMerge( + target: Record, + source: Record, +): Record { + if (source === null || typeof source !== "object") return target; + if (Array.isArray(source)) return source; + + const result: Record = { ...target }; + + for (const key in source) { + const sourceValue = source[key]; + const targetValue = result[key]; + + if ( + sourceValue !== null && + typeof sourceValue === "object" && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === "object" && + !Array.isArray(targetValue) + ) { + // Recursively merge nested objects + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record, + ); + } else if (sourceValue !== undefined) { + result[key] = sourceValue; + } + } + + return result; +} + +/** + * Discovers configuration files in order of precedence: + * 1. Global config (XDG/AppData) + * 2. Project root config + * 3. .opencode config + */ +export async function discoverConfigFiles(projectRoot?: string): Promise { + const paths: string[] = []; + + // 1. Global config + const globalPath = + process.platform === "win32" + ? // Convert forward slashes to backslashes for Windows paths (e.g., "a/b" -> "a\b") + `${process.env.APPDATA || homedir()}\\${CONFIG_FILE_NAME}`.replace(/\//g, "\\") // windows + : `${process.env.XDG_CONFIG_HOME || `${homedir()}/.config`}/${CONFIG_FILE_NAME}`; // linux/macos + + if (await Bun.file(globalPath).exists()) { + paths.push(globalPath); + } + + // 2. Project root config + if (projectRoot) { + const projectPath = `${projectRoot}/${CONFIG_FILE_NAME}`; + if (await Bun.file(projectPath).exists()) { + paths.push(projectPath); + } + + // 3. .opencode config + const dotOpenCodePath = `${projectRoot}/.opencode/${CONFIG_FILE_NAME}`; + if (await Bun.file(dotOpenCodePath).exists()) { + paths.push(dotOpenCodePath); + } + } + + return paths; +} + +/** + * Loads and validates configuration from all discovered config files. + * Configs are merged in order of discovery (lower precedence first). + * + * @throws {ConfigError} If a config file has invalid JSON or validation fails. + * This ensures the plugin fails fast on misconfiguration rather than + * silently using defaults. + */ +export async function loadConfig(projectRoot?: string): Promise { + const filePaths = await discoverConfigFiles(projectRoot); + const configs: Record[] = []; + + for (const filePath of filePaths) { + try { + const content = await Bun.file(filePath).json(); + if (content && typeof content === "object") { + configs.push(content as Record); + } + } catch (error) { + // JSON parse errors are fatal - user has a config file but it's broken + if (error instanceof SyntaxError) { + throw new ConfigError(`Invalid JSON in ${filePath}: ${error.message}`); + } + // Other errors (e.g., file not readable) are also fatal + throw new ConfigError( + `Failed to read config file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + const mergedConfig = configs.reduce>( + (acc, config) => deepMerge(acc, config), + {}, + ); + + try { + return ConfigSchema.parse(mergedConfig); + } catch (error) { + // Zod validation errors - rethrow as ConfigError for consistent error handling + const message = error instanceof Error ? error.message : String(error); + throw new ConfigError(`Config validation failed: ${message}`); + } +} diff --git a/src/config/resolver.ts b/src/config/resolver.ts new file mode 100644 index 0000000..c909452 --- /dev/null +++ b/src/config/resolver.ts @@ -0,0 +1,51 @@ +import type { Event } from "@opencode-ai/sdk"; +import type { Config, EventConfig } from "@/config/schema"; + +type EventType = Event["type"] | "permission.asked" | "permission.replied" | "question.asked"; +const EVENT_CONFIG_MAP: Record = { + "session.idle": "response_ready", + "session.error": "error", + "permission.asked": "permission_asked", + "question.asked": "question_asked", +}; + +export interface ResolvedConfig { + readonly globalEnabled: boolean; + isEnabled(eventType: EventType): boolean; + getDelay(eventType: EventType): number; +} + +export function createResolvedConfig(config: Config): ResolvedConfig { + const globalEnabled = config.enabled; + + return { + get globalEnabled() { + return globalEnabled; + }, + + isEnabled(eventType: EventType): boolean { + const configKey = EVENT_CONFIG_MAP[eventType]; + // TS infers config[keyof Config] as union; cast safe since Zod validates + configKey from EVENT_CONFIG_MAP + const eventConfig: EventConfig | undefined = configKey + ? (config[configKey] as EventConfig) + : undefined; + + if (eventConfig?.enabled) { + return eventConfig.enabled; + } + + return globalEnabled; + }, + + getDelay(eventType: EventType): number { + const configKey = EVENT_CONFIG_MAP[eventType]; + // TS infers config[keyof Config] as union; cast safe since Zod validates + configKey from EVENT_CONFIG_MAP + const eventConfig: EventConfig | undefined = configKey + ? (config[configKey] as EventConfig) + : undefined; + const delayMS = eventConfig?.delay ?? config.delay; + + return delayMS * 1000; + }, + }; +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..31c921f --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,24 @@ +import * as z from "zod"; + +export const EventConfigSchema = z.object({ + enabled: z.boolean().default(true).describe("Enable notifications for this event type"), + delay: z.number().optional().describe("Optional override for notification delay in seconds"), +}); + +export const ConfigSchema = z.object({ + delay: z.number().default(15).describe("Default notification delay in seconds"), + enabled: z.boolean().default(true).describe("Master switch to enable/disable all notifications"), + response_ready: EventConfigSchema.default({ enabled: true }).describe( + "Notification when AI response is ready", + ), + error: EventConfigSchema.default({ enabled: true }).describe("Notification on session error"), + permission_asked: EventConfigSchema.default({ enabled: true }).describe( + "Notification when permission is requested", + ), + question_asked: EventConfigSchema.default({ enabled: true }).describe( + "Notification when a question is asked", + ), +}); + +export type EventConfig = z.infer; +export type Config = z.infer; diff --git a/src/notification-scheduler.ts b/src/notification-scheduler.ts index ffe2c66..3b04f5b 100644 --- a/src/notification-scheduler.ts +++ b/src/notification-scheduler.ts @@ -1,32 +1,39 @@ +import type { Event } from "@opencode-ai/sdk"; +import type { ResolvedConfig } from "@/config/resolver"; import { Notification } from "@/notification"; -export const DELAY_MS = 15 * 1000; +type EventType = Event["type"] | "permission.asked" | "permission.replied" | "question.asked"; +type Schedule = (sessionId: string, title: string, message: string, eventType: EventType) => void; interface PendingNotification { title: string; message: string; timeoutId: ReturnType; } - -export interface NotificationScheduler { - schedule: (sessionId: string, title: string, message: string) => void; +interface NotificationScheduler { + schedule: Schedule; cancelForSession: (sessionId: string) => void; cancelAll: () => void; } -export function createNotificationScheduler(): NotificationScheduler { +export function createNotificationScheduler(resolvedConfig: ResolvedConfig): NotificationScheduler { const pendingBySession = new Map(); - const schedule = (sessionId: string, title: string, message: string): void => { + const schedule: Schedule = (sessionId, title, message, eventType) => { + if (eventType && !resolvedConfig.isEnabled(eventType)) { + return; + } + const existing = pendingBySession.get(sessionId); if (existing) { clearTimeout(existing.timeoutId); } + const delay = resolvedConfig.getDelay(eventType); const timeoutId = setTimeout(() => { Notification.notify({ title, message }); pendingBySession.delete(sessionId); - }, DELAY_MS); + }, delay); pendingBySession.set(sessionId, { title, message, timeoutId }); }; diff --git a/tsconfig.json b/tsconfig.json index f131acf..e3a1b66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,6 @@ "@/*": ["src/*"] } }, - "include": ["*.ts", "src/**/*.ts"], + "include": ["*.ts", "src/**/*.ts", "scripts/**/*.ts"], "exclude": ["node_modules", "dist"] }