feat(notify): bundle KDCO notify with ownership migration
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {}),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user