From f5a11281f81c3461a58e5388108c957f7ee214e1 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sun, 19 Apr 2026 14:07:08 +0800 Subject: [PATCH] feat(notify): bundle KDCO notify with ownership migration --- docs/guide/installation.md | 8 +- docs/reference/configuration.md | 3 +- docs/reference/features.md | 2 +- package.json | 3 +- postinstall.mjs | 30 +- script/build-bundled-notify.ts | 23 ++ .../THIRD_PARTY_NOTICES.md | 10 + src/bundled-opencode-notify/index.test.ts | 112 ++++++ src/bundled-opencode-notify/index.ts | 166 +++++++++ src/hooks/session-todo-status.ts | 5 +- src/index.telemetry.test.ts | 9 + src/index.test.ts | 12 + src/index.ts | 4 + src/shared/bundled-notify-ownership.test.ts | 188 ++++++++++ src/shared/bundled-notify-ownership.ts | 341 ++++++++++++++++++ src/shared/index.ts | 1 + src/shared/migration/config-migration.ts | 2 +- 17 files changed, 906 insertions(+), 13 deletions(-) create mode 100644 script/build-bundled-notify.ts create mode 100644 src/bundled-opencode-notify/THIRD_PARTY_NOTICES.md create mode 100644 src/bundled-opencode-notify/index.test.ts create mode 100644 src/bundled-opencode-notify/index.ts create mode 100644 src/shared/bundled-notify-ownership.test.ts create mode 100644 src/shared/bundled-notify-ownership.ts diff --git a/docs/guide/installation.md b/docs/guide/installation.md index ecede58d9..a195c5c3b 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -36,13 +36,9 @@ oh-my-opencode removed built-in `session-notification` handling. - Removed hook key: `session-notification` - Removed config key: `notification.force_enable` -For session alerts and cmux-related terminal UX, install KDCO `opencode-notify` (`kdco/notify`) in your OpenCode plugin list. +oh-my-opencode now bundles KDCO `opencode-notify` and auto-manages a single user-scope bundled notify plugin entry. No separate registry/plugin installation is required. -```json -{ - "plugin": ["oh-my-openagent", "kdco/notify"] -} -``` +If your config already has recognized `kdco/notify` entries, oh-my-opencode migrates them to the bundled owner automatically. Custom/unsafe notify entries are blocked with explicit cleanup guidance to avoid duplicate owners. `background-notification` behavior in oh-my-opencode is unchanged. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index e5d6be4df..75cbc1ac4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -592,8 +592,9 @@ Built-in `session-notification` support was removed from oh-my-opencode. - Removed hook key: `session-notification` - Removed config key: `notification.force_enable` - Migration automatically cleans both from legacy configs at startup +- oh-my-opencode now bundles KDCO notify and auto-registers one user-scope bundled notify plugin entry -For session alerts and cmux-related terminal UX, install KDCO `opencode-notify` (`kdco/notify`) in your OpenCode plugin list. +Do not add separate `kdco/notify` entries manually. Recognized legacy `kdco/notify` entries are migrated to the bundled owner. Custom/unsafe notify entries fail loudly with cleanup guidance to prevent duplicate owners. `background-notification` is unchanged because it handles parent-session reminder injection for background tasks, not notification transport. diff --git a/docs/reference/features.md b/docs/reference/features.md index c7b865d31..848bb6512 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -792,7 +792,7 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across | **agent-usage-reminder** | PostToolUse + Event | Reminds you to leverage specialized agents for better results. | | **question-label-truncator** | PreToolUse | Truncates long question labels in the Question tool UI. | -Session-level OS alerts are now provided by KDCO `opencode-notify` (`kdco/notify`). oh-my-opencode no longer owns built-in `session-notification` transport. +Session-level OS alerts are provided by a bundled KDCO `opencode-notify` integration managed by oh-my-opencode. Separate external `kdco/notify` plugin installs are not required. #### Task Management diff --git a/package.json b/package.json index d00e0e274..bfb3c5e3b 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "./schema.json": "./dist/oh-my-opencode.schema.json" }, "scripts": { - "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", + "build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun build src/bundled-opencode-notify/index.ts --outdir dist/opencode-notify --target bun --format esm --external @ast-grep/napi --external zod && bun run build:bundled-notify && bun run build:schema", "build:all": "bun run build && bun run build:binaries", "build:binaries": "bun run script/build-binaries.ts", "build:schema": "bun run script/build-schema.ts", + "build:bundled-notify": "bun run script/build-bundled-notify.ts", "build:model-capabilities": "bun run script/build-model-capabilities.ts", "clean": "rm -rf dist", "prepare": "bun run build", diff --git a/postinstall.mjs b/postinstall.mjs index cdebb7e68..50c8489dc 100644 --- a/postinstall.mjs +++ b/postinstall.mjs @@ -1,8 +1,10 @@ // postinstall.mjs // Runs after npm install to verify platform binary is available -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { dirname } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js"; const require = createRequire(import.meta.url); @@ -128,6 +130,32 @@ function main() { console.warn(` The CLI may not work on this platform.`); // Don't fail installation - let user try anyway } + + void runBundledNotifyBootstrap(); +} + +async function runBundledNotifyBootstrap() { + try { + const packageRoot = dirname(fileURLToPath(import.meta.url)); + const ownershipModulePath = `${packageRoot}/dist/shared/bundled-notify-ownership.js`; + if (!existsSync(ownershipModulePath)) { + return; + } + + const ownershipModule = await import(pathToFileURL(ownershipModulePath).href); + if (typeof ownershipModule.ensureBundledNotifyOwnership !== "function") { + return; + } + + ownershipModule.ensureBundledNotifyOwnership({ + projectDirectory: process.cwd(), + packageRoot, + env: process.env, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`⚠ oh-my-opencode bundled notify bootstrap: ${message}`); + } } main(); diff --git a/script/build-bundled-notify.ts b/script/build-bundled-notify.ts new file mode 100644 index 000000000..94a05b0c7 --- /dev/null +++ b/script/build-bundled-notify.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env bun +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const DIST_NOTIFY_DIR = join("dist", "opencode-notify") +const SOURCE_NOTICE_PATH = join("src", "bundled-opencode-notify", "THIRD_PARTY_NOTICES.md") +const DIST_NOTICE_PATH = join(DIST_NOTIFY_DIR, "THIRD_PARTY_NOTICES.md") +const DIST_PACKAGE_PATH = join(DIST_NOTIFY_DIR, "package.json") + +const bundledPackageJson = { + name: "@oh-my-openagent/bundled-opencode-notify", + private: true, + type: "module", + main: "./index.js", +} + +function main(): void { + mkdirSync(DIST_NOTIFY_DIR, { recursive: true }) + writeFileSync(DIST_PACKAGE_PATH, `${JSON.stringify(bundledPackageJson, null, 2)}\n`, "utf-8") + copyFileSync(SOURCE_NOTICE_PATH, DIST_NOTICE_PATH) +} + +main() diff --git a/src/bundled-opencode-notify/THIRD_PARTY_NOTICES.md b/src/bundled-opencode-notify/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..0b58a4263 --- /dev/null +++ b/src/bundled-opencode-notify/THIRD_PARTY_NOTICES.md @@ -0,0 +1,10 @@ +# Third-Party Notices — Bundled Notify Plugin + +This directory contains the bundled notify plugin artifact shipped by `oh-my-opencode`. + +## KDCO `opencode-notify` + +- Upstream project identifier: `kdco/notify` +- Usage in this package: bundled local plugin ownership target for OpenCode `plugin` registration + +If you update bundled notify behavior, review and refresh this notice file with any additional upstream licensing requirements. diff --git a/src/bundled-opencode-notify/index.test.ts b/src/bundled-opencode-notify/index.test.ts new file mode 100644 index 000000000..94b94d6a2 --- /dev/null +++ b/src/bundled-opencode-notify/index.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test" + +import bundledNotifyPlugin from "./index" + +interface TodoItem { + status: string +} + +function createMockShellExecutor(notificationCommands: string[]) { + return (cmd: TemplateStringsArray | string, ...values: unknown[]) => { + const command = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") + + const isLookupCommand = command.includes("command -v terminal-notifier") + const exitCode = isLookupCommand ? 1 : 0 + if (!isLookupCommand) { + notificationCommands.push(command) + } + + const result = { stdout: "", stderr: "", exitCode } + const promise = Promise.resolve(result) as Promise & { + quiet: () => Promise + nothrow: () => Promise & { quiet: () => Promise } + } + + promise.quiet = () => promise + promise.nothrow = () => { + const inner = Promise.resolve(result) as Promise & { quiet: () => Promise } + inner.quiet = () => inner + return inner + } + + return promise + } +} + +function createPluginInput(todos: TodoItem[], notificationCommands: string[]) { + return { + $: createMockShellExecutor(notificationCommands), + client: { + session: { + todo: async () => ({ data: todos }), + }, + }, + } as Parameters[0] +} + +describe("bundled-opencode-notify idle suppression", () => { + beforeEach(() => { + jest.useFakeTimers() + }) + + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + test("suppresses ready notification when todos are incomplete", async () => { + // given + const notificationCommands: string[] = [] + const hooks = await bundledNotifyPlugin.server( + createPluginInput([{ status: "in_progress" }], notificationCommands), + ) + + // when + await hooks.event?.({ event: { type: "session.idle", properties: { sessionID: "session-1" } } }) + jest.advanceTimersByTime(1500) + await Promise.resolve() + await Promise.resolve() + + // then + expect(notificationCommands).toHaveLength(0) + }) + + test("sends ready notification when todos are complete", async () => { + // given + const notificationCommands: string[] = [] + const hooks = await bundledNotifyPlugin.server( + createPluginInput([{ status: "completed" }], notificationCommands), + ) + + // when + await hooks.event?.({ event: { type: "session.idle", properties: { sessionID: "session-2" } } }) + jest.advanceTimersByTime(1500) + await Promise.resolve() + await Promise.resolve() + + // then + expect(notificationCommands.length).toBeGreaterThan(0) + }) + + test("sends ready notification when remaining todos are only blocked or deleted", async () => { + // given + const notificationCommands: string[] = [] + const hooks = await bundledNotifyPlugin.server( + createPluginInput([ + { status: "blocked" }, + { status: "deleted" }, + ], notificationCommands), + ) + + // when + await hooks.event?.({ event: { type: "session.idle", properties: { sessionID: "session-3" } } }) + jest.advanceTimersByTime(1500) + await Promise.resolve() + await Promise.resolve() + + // then + expect(notificationCommands.length).toBeGreaterThan(0) + }) +}) diff --git a/src/bundled-opencode-notify/index.ts b/src/bundled-opencode-notify/index.ts new file mode 100644 index 000000000..6d20bf282 --- /dev/null +++ b/src/bundled-opencode-notify/index.ts @@ -0,0 +1,166 @@ +import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin" +import { hasIncompleteTodos } from "../hooks/session-todo-status" + +type Platform = "darwin" | "linux" | "win32" | "unsupported" + +interface SessionState { + isSubagent: boolean + idleTimer: ReturnType | null +} + +function detectPlatform(): Platform { + if (process.platform === "darwin") return "darwin" + if (process.platform === "linux") return "linux" + if (process.platform === "win32") return "win32" + return "unsupported" +} + +function getSessionId(properties: unknown): string | null { + if (!properties || typeof properties !== "object") return null + const props = properties as Record + + if (typeof props.sessionID === "string" && props.sessionID.length > 0) return props.sessionID + + const info = props.info + if (!info || typeof info !== "object") return null + const infoRecord = info as Record + if (typeof infoRecord.sessionID === "string" && infoRecord.sessionID.length > 0) return infoRecord.sessionID + if (typeof infoRecord.id === "string" && infoRecord.id.length > 0) return infoRecord.id + return null +} + +async function sendDarwinNotification(ctx: Parameters[0], title: string, message: string): Promise { + const escapedTitle = title.replace(/"/g, '\\"') + const escapedMessage = message.replace(/"/g, '\\"') + + await ctx.$`command -v terminal-notifier`.nothrow().quiet() + .then((result) => { + if (result.exitCode !== 0) { + return ctx.$`osascript -e ${`display notification "${escapedMessage}" with title "${escapedTitle}"`}`.nothrow().quiet() + } + + return ctx.$`terminal-notifier -title ${title} -message ${message}`.nothrow().quiet() + }) +} + +async function sendLinuxNotification(ctx: Parameters[0], title: string, message: string): Promise { + await ctx.$`notify-send ${title} ${message}`.nothrow().quiet() +} + +async function sendWindowsNotification(ctx: Parameters[0], title: string, message: string): Promise { + const escapedTitle = title.replace(/'/g, "''") + const escapedMessage = message.replace(/'/g, "''") + const script = ` +Add-Type -AssemblyName System.Windows.Forms | Out-Null +$notify = New-Object System.Windows.Forms.NotifyIcon +$notify.Icon = [System.Drawing.SystemIcons]::Information +$notify.BalloonTipTitle = '${escapedTitle}' +$notify.BalloonTipText = '${escapedMessage}' +$notify.Visible = $true +$notify.ShowBalloonTip(3000) +Start-Sleep -Milliseconds 3500 +$notify.Dispose() +` + await ctx.$`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ${script}`.nothrow().quiet() +} + +async function sendSessionNotification(ctx: Parameters[0], title: string, message: string): Promise { + const platform = detectPlatform() + if (platform === "unsupported") return + + if (platform === "darwin") { + await sendDarwinNotification(ctx, title, message) + return + } + + if (platform === "linux") { + await sendLinuxNotification(ctx, title, message) + return + } + + await sendWindowsNotification(ctx, title, message) +} + +async function sendIdleReadyNotification(ctx: Parameters[0], sessionID: string): Promise { + const hasPendingTodos = await hasIncompleteTodos(ctx, sessionID) + if (hasPendingTodos) return + await sendSessionNotification(ctx, "OpenCode", "Agent is ready for input") +} + +const ACTIVITY_EVENTS = new Set(["message.updated", "session.status"]) +const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"]) + +const serverPlugin: Plugin = async (input): Promise => { + const sessionState = new Map() + + function getOrCreateSession(sessionID: string): SessionState { + const existing = sessionState.get(sessionID) + if (existing) return existing + const created: SessionState = { isSubagent: false, idleTimer: null } + sessionState.set(sessionID, created) + return created + } + + function clearIdleTimer(state: SessionState): void { + if (!state.idleTimer) return + clearTimeout(state.idleTimer) + state.idleTimer = null + } + + return { + "tool.execute.before": async (toolInput: { tool: string; sessionID?: string | null }): Promise => { + const sessionID = typeof toolInput.sessionID === "string" && toolInput.sessionID.length > 0 + ? toolInput.sessionID + : null + if (!sessionID) return + + const state = getOrCreateSession(sessionID) + if (state.isSubagent) return + clearIdleTimer(state) + + const normalizedToolName = toolInput.tool.toLowerCase() + if (!QUESTION_TOOLS.has(normalizedToolName)) return + await sendSessionNotification(input, "OpenCode", "Agent is asking a question") + }, + + event: async ({ event }): Promise => { + const sessionID = getSessionId(event.properties) + if (!sessionID) return + + const state = getOrCreateSession(sessionID) + + if (event.type === "session.created") { + const info = (event.properties as { info?: { parentID?: string } } | undefined)?.info + state.isSubagent = typeof info?.parentID === "string" && info.parentID.length > 0 + clearIdleTimer(state) + return + } + + if (event.type === "session.deleted") { + clearIdleTimer(state) + sessionState.delete(sessionID) + return + } + + if (state.isSubagent) return + + if (ACTIVITY_EVENTS.has(event.type)) { + clearIdleTimer(state) + } + + if (event.type !== "session.idle") return + + clearIdleTimer(state) + state.idleTimer = setTimeout(() => { + void sendIdleReadyNotification(input, sessionID) + }, 1500) + }, + } +} + +const pluginModule: PluginModule = { + id: "oh-my-openagent-bundled-notify", + server: serverPlugin, +} + +export default pluginModule diff --git a/src/hooks/session-todo-status.ts b/src/hooks/session-todo-status.ts index c86752fe5..d1705ef7d 100644 --- a/src/hooks/session-todo-status.ts +++ b/src/hooks/session-todo-status.ts @@ -1,11 +1,12 @@ import type { PluginInput } from "@opencode-ai/plugin" import { normalizeSDKResponse } from "../shared" +import { getIncompleteCount } from "./todo-continuation-enforcer/todo" interface Todo { content: string status: string priority: string - id: string + id?: string } export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { @@ -13,7 +14,7 @@ export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): P const response = await ctx.client.session.todo({ path: { id: sessionID } }) const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true }) if (!todos || todos.length === 0) return false - return todos.some((todo) => todo.status !== "completed" && todo.status !== "cancelled") + return getIncompleteCount(todos) > 0 } catch { return false } diff --git a/src/index.telemetry.test.ts b/src/index.telemetry.test.ts index 924a7db2c..d7831788e 100644 --- a/src/index.telemetry.test.ts +++ b/src/index.telemetry.test.ts @@ -39,6 +39,12 @@ const mockCreatePluginPostHog = mock(() => ({ shutdown: mock(async () => {}), })) const mockGetPostHogDistinctId = mock(() => "plugin-distinct-id") +const mockEnsureBundledNotifyOwnership = mock(() => ({ + skipped: false, + changedUserConfig: false, + changedProjectConfig: false, + canonicalEntry: "file:///tmp/dist/opencode-notify", +})) function installModuleMocks(): void { mock.module("./cli/config-manager/config-context", () => ({ @@ -48,6 +54,9 @@ function installModuleMocks(): void { detectExternalSkillPlugin: mock(() => ({ detected: false, pluginName: null })), getSkillPluginConflictWarning: mock(() => ""), })) + mock.module("./shared/bundled-notify-ownership", () => ({ + ensureBundledNotifyOwnership: mockEnsureBundledNotifyOwnership, + })) mock.module("./shared", () => ({ injectServerAuthIntoClient: mockInjectServerAuthIntoClient, log: mock(() => {}), diff --git a/src/index.test.ts b/src/index.test.ts index ba7be1363..fba9b4792 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -37,6 +37,12 @@ const mockCreateHooks = mock(() => ({ const mockCreatePluginInterface = mock(() => ({})) const mockInitializeOpenClaw = mock(async () => {}) const mockStartTmuxCheck = mock(() => {}) +const mockEnsureBundledNotifyOwnership = mock(() => ({ + skipped: false, + changedUserConfig: false, + changedProjectConfig: false, + canonicalEntry: "file:///tmp/dist/opencode-notify", +})) let pluginModule: (typeof import("./index"))["default"] @@ -50,6 +56,10 @@ function installIndexModuleMocks(): void { getSkillPluginConflictWarning: mockGetSkillPluginConflictWarning, })) + mock.module("./shared/bundled-notify-ownership", () => ({ + ensureBundledNotifyOwnership: mockEnsureBundledNotifyOwnership, + })) + mock.module("./shared", () => ({ injectServerAuthIntoClient: mockInjectServerAuthIntoClient, log: mock(() => {}), @@ -130,6 +140,7 @@ describe("oh-my-openagent plugin module", () => { mockCreatePluginInterface.mockClear() mockInitializeOpenClaw.mockClear() mockStartTmuxCheck.mockClear() + mockEnsureBundledNotifyOwnership.mockClear() }) afterEach(() => { @@ -157,6 +168,7 @@ describe("oh-my-openagent plugin module", () => { } as Parameters[0]) // then + expect(mockEnsureBundledNotifyOwnership).toHaveBeenCalledWith({ projectDirectory: "/tmp/project" }) expect(mockInitializeOpenClaw).toHaveBeenCalledTimes(1) expect(mockInitializeOpenClaw).toHaveBeenCalledWith(openclawConfig) }) diff --git a/src/index.ts b/src/index.ts index 6778427d0..6effd117c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { injectServerAuthIntoClient, log, logLegacyPluginStartupWarning } from " import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector" import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog" +import { ensureBundledNotifyOwnership } from "./shared/bundled-notify-ownership" const serverPlugin: Plugin = async (input, _options): Promise => { initConfigContext("opencode", null) @@ -24,6 +25,9 @@ const serverPlugin: Plugin = async (input, _options): Promise => { directory: input.directory, }) logLegacyPluginStartupWarning() + ensureBundledNotifyOwnership({ + projectDirectory: input.directory, + }) const skillPluginCheck = detectExternalSkillPlugin(input.directory) if (skillPluginCheck.detected && skillPluginCheck.pluginName) { diff --git a/src/shared/bundled-notify-ownership.test.ts b/src/shared/bundled-notify-ownership.test.ts new file mode 100644 index 000000000..4ec140302 --- /dev/null +++ b/src/shared/bundled-notify-ownership.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { parseJsoncSafe } from "./jsonc-parser" +import { ensureBundledNotifyOwnership, getBundledNotifyCanonicalEntry } from "./bundled-notify-ownership" + +interface OpenCodeConfig { + plugin?: unknown[] +} + +function readConfig(path: string): OpenCodeConfig { + const result = parseJsoncSafe(readFileSync(path, "utf-8")) + if (!result.data) { + throw new Error(`Failed to parse config: ${path}`) + } + + return result.data +} + +describe("ensureBundledNotifyOwnership", () => { + let rootDir = "" + let projectDir = "" + let userConfigDir = "" + let packageRoot = "" + let canonicalEntry = "" + + beforeEach(() => { + rootDir = join(tmpdir(), `omo-bundled-notify-${Date.now()}-${Math.random().toString(16).slice(2)}`) + projectDir = join(rootDir, "project") + userConfigDir = join(rootDir, "user-config") + packageRoot = join(rootDir, "package") + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(packageRoot, "dist", "opencode-notify"), { recursive: true }) + canonicalEntry = getBundledNotifyCanonicalEntry(packageRoot) + process.env.OPENCODE_CONFIG_DIR = userConfigDir + delete process.env.OMO_DISABLE_BUNDLED_NOTIFY_BOOTSTRAP + }) + + afterEach(() => { + delete process.env.OPENCODE_CONFIG_DIR + delete process.env.OMO_DISABLE_BUNDLED_NOTIFY_BOOTSTRAP + rmSync(rootDir, { recursive: true, force: true }) + }) + + test("adds bundled notify to user config when no notify owner exists", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n") + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.changedUserConfig).toBe(true) + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) + }) + + test("creates user opencode.json with bundled owner when user config is missing", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.changedUserConfig).toBe(true) + expect(readConfig(userConfigPath).plugin).toEqual([canonicalEntry]) + }) + + test("rewrites recognized external notify in user config to bundled owner", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify@1.2.3", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.changedUserConfig).toBe(true) + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) + }) + + test("rewrites recognized tuple notify in user config when tuple options are empty", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["kdco/notify", {}], "oh-my-openagent"] }, null, 2) + "\n", + ) + + // when + ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) + }) + + test("removes project recognized notify and adds bundled user owner", () => { + // given + const projectConfigPath = join(projectDir, ".opencode", "opencode.json") + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(projectConfigPath, JSON.stringify({ plugin: ["kdco/notify"] }, null, 2) + "\n") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n") + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.changedProjectConfig).toBe(true) + expect(result.changedUserConfig).toBe(true) + expect(readConfig(projectConfigPath).plugin).toEqual([]) + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) + }) + + test("rewrites user recognized owner and removes project recognized duplicate", () => { + // given + const projectConfigPath = join(projectDir, ".opencode", "opencode.json") + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(projectConfigPath, JSON.stringify({ plugin: ["npm:kdco/notify"] }, null, 2) + "\n") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify", "oh-my-openagent"] }, null, 2) + "\n") + + // when + ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(readConfig(projectConfigPath).plugin).toEqual([]) + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent", canonicalEntry]) + }) + + test("fails loudly for custom unsafe notify entry in user config", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["file:///custom/plugins/opencode-notify"] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(userConfigPath).plugin).toEqual(["file:///custom/plugins/opencode-notify"]) + }) + + test("fails loudly for custom unsafe notify tuple in project config", () => { + // given + const projectConfigPath = join(projectDir, ".opencode", "opencode.json") + writeFileSync(projectConfigPath, JSON.stringify({ plugin: [["kdco/notify", { mode: "custom" }]] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Unsafe external notify plugin ownership detected") + expect(readConfig(projectConfigPath).plugin).toEqual([["kdco/notify", { mode: "custom" }]]) + }) + + test("fails loudly when project reintroduces recognized external notify after bundled owner exists", () => { + // given + const projectConfigPath = join(projectDir, ".opencode", "opencode.json") + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(projectConfigPath, JSON.stringify({ plugin: ["kdco/notify"] }, null, 2) + "\n") + writeFileSync(userConfigPath, JSON.stringify({ plugin: [canonicalEntry, "oh-my-openagent"] }, null, 2) + "\n") + + // when + const run = () => ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(run).toThrow("Duplicate notify owners detected") + expect(readConfig(projectConfigPath).plugin).toEqual(["kdco/notify"]) + expect(readConfig(userConfigPath).plugin).toEqual([canonicalEntry, "oh-my-openagent"]) + }) + + test("skips bootstrap when disable env is set", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n") + process.env.OMO_DISABLE_BUNDLED_NOTIFY_BOOTSTRAP = "1" + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.skipped).toBe(true) + expect(readConfig(userConfigPath).plugin).toEqual(["oh-my-openagent"]) + }) +}) diff --git a/src/shared/bundled-notify-ownership.ts b/src/shared/bundled-notify-ownership.ts new file mode 100644 index 000000000..ac9189100 --- /dev/null +++ b/src/shared/bundled-notify-ownership.ts @@ -0,0 +1,341 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs" +import { dirname, join, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +import { applyEdits, modify } from "jsonc-parser" + +import { parseJsoncSafe } from "./jsonc-parser" +import { getOpenCodeConfigPaths } from "./opencode-config-dir" +import { writeFileAtomically } from "./write-file-atomically" + +type ConfigFormat = "json" | "jsonc" | "none" +type ConfigScope = "project" | "user" + +type OpenCodePluginEntry = string | [string, ...unknown[]] + +interface OpenCodeConfig { + plugin?: OpenCodePluginEntry[] + [key: string]: unknown +} + +interface ScopeConfig { + scope: ConfigScope + format: ConfigFormat + path: string + content: string | null + data: OpenCodeConfig + pluginEntries: OpenCodePluginEntry[] +} + +interface ClassifiedEntry { + kind: "bundled" | "recognized-external" | "unsafe-external" | "other" + entry: OpenCodePluginEntry + index: number + reason?: string +} + +export interface BundledNotifyOwnershipResult { + skipped: boolean + changedUserConfig: boolean + changedProjectConfig: boolean + canonicalEntry: string +} + +export interface EnsureBundledNotifyOwnershipArgs { + projectDirectory: string + packageRoot?: string + env?: NodeJS.ProcessEnv +} + +const BUNDLED_NOTIFY_DISABLE_ENV = "OMO_DISABLE_BUNDLED_NOTIFY_BOOTSTRAP" +const KNOWN_EXTERNAL_NOTIFY_IDS = ["kdco/notify", "npm:kdco/notify"] as const + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isPathLikePluginEntry(entry: string): boolean { + if (entry.startsWith("file://")) return true + if (entry.startsWith("./") || entry.startsWith("../") || entry.startsWith("/") || entry.startsWith("~/")) return true + if (/^[A-Za-z]:[\\/]/.test(entry)) return true + return false +} + +function isRecognizedExternalNotifyId(entry: string): boolean { + const normalized = entry.trim().toLowerCase() + return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => normalized === base || normalized.startsWith(`${base}@`)) +} + +function looksLikeNotifyPlugin(entry: string): boolean { + const normalized = entry.trim().toLowerCase() + return normalized.includes("kdco/notify") + || normalized.includes("opencode-notify") + || normalized.includes("notify") +} + +function areTupleOptionsEmptyOrDefault(options: unknown[]): boolean { + if (options.length === 0) return true + if (options.every((option) => option === null || option === undefined)) return true + + if (options.length !== 1) return false + const firstOption = options[0] + if (Array.isArray(firstOption)) return firstOption.length === 0 + if (!isPlainObject(firstOption)) return false + return Object.keys(firstOption).length === 0 +} + +function classifyPluginEntry(entry: OpenCodePluginEntry, index: number, canonicalEntry: string): ClassifiedEntry { + if (typeof entry === "string") { + if (entry === canonicalEntry) { + return { kind: "bundled", entry, index } + } + + if (isRecognizedExternalNotifyId(entry)) { + return { kind: "recognized-external", entry, index } + } + + if (!looksLikeNotifyPlugin(entry)) { + return { kind: "other", entry, index } + } + + if (isPathLikePluginEntry(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "path-based notify plugin entries are not auto-migrated", + } + } + + return { + kind: "unsafe-external", + entry, + index, + reason: "notify plugin entry is not an exact recognized kdco/notify identifier", + } + } + + const [tupleKey, ...tupleOptions] = entry + if (tupleKey === canonicalEntry) { + if (areTupleOptionsEmptyOrDefault(tupleOptions)) { + return { kind: "bundled", entry, index } + } + + return { + kind: "unsafe-external", + entry, + index, + reason: "bundled notify entry must not include custom tuple options", + } + } + + if (isRecognizedExternalNotifyId(tupleKey)) { + if (areTupleOptionsEmptyOrDefault(tupleOptions)) { + return { kind: "recognized-external", entry, index } + } + + return { + kind: "unsafe-external", + entry, + index, + reason: "recognized kdco/notify tuple has non-empty custom options", + } + } + + if (looksLikeNotifyPlugin(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "tuple-based notify entry is not an exact recognized kdco/notify identifier", + } + } + + return { kind: "other", entry, index } +} + +function getProjectOpenCodeConfigPath(projectDirectory: string): { format: ConfigFormat; path: string } { + const baseDir = join(projectDirectory, ".opencode") + const jsoncPath = join(baseDir, "opencode.jsonc") + const jsonPath = join(baseDir, "opencode.json") + + if (existsSync(jsoncPath)) return { format: "jsonc", path: jsoncPath } + if (existsSync(jsonPath)) return { format: "json", path: jsonPath } + return { format: "none", path: jsonPath } +} + +function getUserOpenCodeConfigPath(): { format: ConfigFormat; path: string } { + const { configJsonc, configJson } = getOpenCodeConfigPaths({ binary: "opencode", version: null }) + if (existsSync(configJsonc)) return { format: "jsonc", path: configJsonc } + if (existsSync(configJson)) return { format: "json", path: configJson } + return { format: "none", path: configJson } +} + +function loadScopeConfig(scope: ConfigScope, format: ConfigFormat, filePath: string): ScopeConfig { + if (format === "none") { + return { + scope, + format, + path: filePath, + content: null, + data: {}, + pluginEntries: [], + } + } + + const content = readFileSync(filePath, "utf-8") + const parseResult = parseJsoncSafe(content) + if (!parseResult.data || !isPlainObject(parseResult.data)) { + throw new Error(`Cannot parse ${scope} OpenCode config: ${filePath}`) + } + + const pluginEntriesRaw = parseResult.data.plugin + const pluginEntries = Array.isArray(pluginEntriesRaw) + ? pluginEntriesRaw.filter((entry): entry is OpenCodePluginEntry => { + if (typeof entry === "string") return true + if (!Array.isArray(entry) || entry.length === 0) return false + return typeof entry[0] === "string" + }) + : [] + + return { + scope, + format, + path: filePath, + content, + data: parseResult.data, + pluginEntries, + } +} + +function writeScopePlugins(scopeConfig: ScopeConfig, pluginEntries: OpenCodePluginEntry[]): void { + const pluginDir = dirname(scopeConfig.path) + mkdirSync(pluginDir, { recursive: true }) + + if (scopeConfig.format === "none" || scopeConfig.format === "json") { + const nextData: OpenCodeConfig = { + ...scopeConfig.data, + plugin: pluginEntries, + } + writeFileAtomically(scopeConfig.path, `${JSON.stringify(nextData, null, 2)}\n`) + return + } + + if (!scopeConfig.content) { + throw new Error(`Cannot rewrite JSONC config without source content: ${scopeConfig.path}`) + } + + const edits = modify(scopeConfig.content, ["plugin"], pluginEntries, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + eol: "\n", + }, + getInsertionIndex: () => 0, + }) + + if (edits.length === 0) return + const nextContent = applyEdits(scopeConfig.content, edits) + writeFileAtomically(scopeConfig.path, nextContent) +} + +function formatUnsafeEntry(scope: ConfigScope, configPath: string, entry: OpenCodePluginEntry, reason: string): string { + return `- ${scope} (${configPath}): ${JSON.stringify(entry)} (${reason})` +} + +function resolvePackageRoot(moduleUrl: string): string { + return resolve(dirname(fileURLToPath(moduleUrl)), "..", "..") +} + +export function getBundledNotifyCanonicalEntry(packageRoot: string): string { + return pathToFileURL(resolve(packageRoot, "dist", "opencode-notify")).href +} + +export function isBundledNotifyBootstrapDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[BUNDLED_NOTIFY_DISABLE_ENV] === "1" +} + +export function ensureBundledNotifyOwnership(args: EnsureBundledNotifyOwnershipArgs): BundledNotifyOwnershipResult { + const env = args.env ?? process.env + const canonicalEntry = getBundledNotifyCanonicalEntry(args.packageRoot ?? resolvePackageRoot(import.meta.url)) + + if (isBundledNotifyBootstrapDisabled(env)) { + return { + skipped: true, + changedUserConfig: false, + changedProjectConfig: false, + canonicalEntry, + } + } + + const projectPath = getProjectOpenCodeConfigPath(args.projectDirectory) + const userPath = getUserOpenCodeConfigPath() + const projectScope = loadScopeConfig("project", projectPath.format, projectPath.path) + const userScope = loadScopeConfig("user", userPath.format, userPath.path) + + const projectClassified = projectScope.pluginEntries.map((entry, index) => classifyPluginEntry(entry, index, canonicalEntry)) + const userClassified = userScope.pluginEntries.map((entry, index) => classifyPluginEntry(entry, index, canonicalEntry)) + + const unsafeEntries = [ + ...projectClassified + .filter((entry) => entry.kind === "unsafe-external") + .map((entry) => formatUnsafeEntry("project", projectScope.path, entry.entry, entry.reason ?? "unsafe")), + ...userClassified + .filter((entry) => entry.kind === "unsafe-external") + .map((entry) => formatUnsafeEntry("user", userScope.path, entry.entry, entry.reason ?? "unsafe")), + ] + + if (unsafeEntries.length > 0) { + throw new Error( + `[oh-my-openagent] Unsafe external notify plugin ownership detected.\n` + + `${unsafeEntries.join("\n")}\n` + + `Remove custom notify entries and keep exactly one user-scope bundled entry:\n${canonicalEntry}`, + ) + } + + const projectRecognized = projectClassified.filter((entry) => entry.kind === "recognized-external") + const userRecognized = userClassified.filter((entry) => entry.kind === "recognized-external") + const projectBundled = projectClassified.filter((entry) => entry.kind === "bundled") + const userBundled = userClassified.filter((entry) => entry.kind === "bundled") + + if (userBundled.length > 0 && projectRecognized.length > 0) { + throw new Error( + `[oh-my-openagent] Duplicate notify owners detected.\n` + + `Project config (${projectScope.path}) reintroduced recognized external kdco/notify entries while bundled ownership is active.\n` + + `Remove project-level kdco/notify entries and keep exactly one user-scope bundled entry:\n${canonicalEntry}`, + ) + } + + const recognizedOrBundledProjectIndexes = new Set([ + ...projectRecognized.map((entry) => entry.index), + ...projectBundled.map((entry) => entry.index), + ]) + + const recognizedOrBundledUserIndexes = new Set([ + ...userRecognized.map((entry) => entry.index), + ...userBundled.map((entry) => entry.index), + ]) + + const nextProjectPlugins = projectScope.pluginEntries.filter((_entry, index) => !recognizedOrBundledProjectIndexes.has(index)) + const userOtherPlugins = userScope.pluginEntries.filter((_entry, index) => !recognizedOrBundledUserIndexes.has(index)) + const nextUserPlugins = [...userOtherPlugins, canonicalEntry] + + const changedProjectConfig = nextProjectPlugins.length !== projectScope.pluginEntries.length + const changedUserConfig = nextUserPlugins.length !== userScope.pluginEntries.length + || nextUserPlugins.some((entry, index) => userScope.pluginEntries[index] !== entry) + + if (changedProjectConfig) { + writeScopePlugins(projectScope, nextProjectPlugins) + } + + if (changedUserConfig) { + writeScopePlugins(userScope, nextUserPlugins) + } + + return { + skipped: false, + changedProjectConfig, + changedUserConfig, + canonicalEntry, + } +} diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c33..6e3c2e1f6 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -75,6 +75,7 @@ export * from "./internal-initiator-marker" export * from "./plugin-command-discovery" export { SessionCategoryRegistry } from "./session-category-registry" export * from "./plugin-identity" +export * from "./bundled-notify-ownership" export * from "./log-legacy-plugin-startup-warning" export * from "./task-system-enabled" export * from "./parse-tools-config" diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index b608e8c64..6895c920a 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -116,7 +116,7 @@ export function migrateConfigFile( if ("notification" in copy) { delete copy.notification needsWrite = true - log("Removed obsolete notification config; use KDCO opencode-notify (kdco/notify) for session alerts") + log("Removed obsolete notification config; session alerts now use the bundled KDCO notify integration") } if (copy.disabled_agents && Array.isArray(copy.disabled_agents)) {