feat(notify): bundle KDCO notify with ownership migration

This commit is contained in:
Kenny
2026-04-19 14:07:08 +08:00
parent af15e8a413
commit f5a11281f8
17 changed files with 906 additions and 13 deletions
+2 -6
View File
@@ -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.
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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",
+29 -1
View File
@@ -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();
+23
View File
@@ -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()
@@ -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.
+112
View File
@@ -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<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
promise.quiet = () => promise
promise.nothrow = () => {
const inner = Promise.resolve(result) as Promise<typeof result> & { quiet: () => Promise<typeof result> }
inner.quiet = () => inner
return inner
}
return promise
}
}
function createPluginInput(todos: TodoItem[], notificationCommands: string[]) {
return {
$: createMockShellExecutor(notificationCommands),
client: {
session: {
todo: async () => ({ data: todos }),
},
},
} as Parameters<typeof bundledNotifyPlugin.server>[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)
})
})
+166
View File
@@ -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<typeof setTimeout> | 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<string, unknown>
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<string, unknown>
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<Plugin>[0], title: string, message: string): Promise<void> {
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<Plugin>[0], title: string, message: string): Promise<void> {
await ctx.$`notify-send ${title} ${message}`.nothrow().quiet()
}
async function sendWindowsNotification(ctx: Parameters<Plugin>[0], title: string, message: string): Promise<void> {
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<Plugin>[0], title: string, message: string): Promise<void> {
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<Plugin>[0], sessionID: string): Promise<void> {
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<Hooks> => {
const sessionState = new Map<string, SessionState>()
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<void> => {
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<void> => {
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
+3 -2
View File
@@ -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<boolean> {
@@ -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
}
+9
View File
@@ -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(() => {}),
+12
View File
@@ -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<typeof pluginModule.server>[0])
// then
expect(mockEnsureBundledNotifyOwnership).toHaveBeenCalledWith({ projectDirectory: "/tmp/project" })
expect(mockInitializeOpenClaw).toHaveBeenCalledTimes(1)
expect(mockInitializeOpenClaw).toHaveBeenCalledWith(openclawConfig)
})
+4
View File
@@ -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<Hooks> => {
initConfigContext("opencode", null)
@@ -24,6 +25,9 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
directory: input.directory,
})
logLegacyPluginStartupWarning()
ensureBundledNotifyOwnership({
projectDirectory: input.directory,
})
const skillPluginCheck = detectExternalSkillPlugin(input.directory)
if (skillPluginCheck.detected && skillPluginCheck.pluginName) {
+188
View File
@@ -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<OpenCodeConfig>(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"])
})
})
+341
View File
@@ -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<string, unknown> {
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<OpenCodeConfig>(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<number>([
...projectRecognized.map((entry) => entry.index),
...projectBundled.map((entry) => entry.index),
])
const recognizedOrBundledUserIndexes = new Set<number>([
...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,
}
}
+1
View File
@@ -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"
+1 -1
View File
@@ -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)) {