diff --git a/AGENTS.md b/AGENTS.md index 02af070b061..70aac6e4a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -OpenCode plugin (npm: `oh-my-opencode`, dual-published as `oh-my-openagent` during transition) extending Claude Code with 11 agents, 52 lifecycle hooks, 26 tools, 3-tier MCP system (built-in + .mcp.json + skill-embedded), Hashline LINE#ID edit tool, IntentGate classifier, and Claude Code compatibility. 1766 TypeScript source files, 377k LOC, 104 barrel index.ts files. Entry: `src/index.ts` → 5-step init (loadConfig → createManagers → createTools → createHooks → createPluginInterface). +OpenCode plugin (npm: `oh-my-opencode`, dual-published as `oh-my-openagent` during transition) extending Claude Code with 11 agents, 51 lifecycle hooks, 26 tools, 3-tier MCP system (built-in + .mcp.json + skill-embedded), Hashline LINE#ID edit tool, IntentGate classifier, and Claude Code compatibility. 1766 TypeScript source files, 377k LOC, 104 barrel index.ts files. Entry: `src/index.ts` → 5-step init (loadConfig → createManagers → createTools → createHooks → createPluginInterface). ## STRUCTURE @@ -14,14 +14,14 @@ oh-my-opencode/ │ ├── index.ts # Plugin entry: default export `pluginModule`, shape `{ id, server }` │ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4) │ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) -│ ├── hooks/ # 52 lifecycle hooks across dedicated modules and standalone files +│ ├── hooks/ # 51 lifecycle hooks across dedicated modules and standalone files │ ├── tools/ # 26 tools across 16 directories (includes Hashline edit with LINE#ID content hashing) │ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, skill-mcp-manager, etc.) │ ├── shared/ # 170+ utility files (barrel-exported, logger → /tmp/oh-my-opencode.log) │ ├── config/ # Zod v4 schema system (32 files) │ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) │ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) -│ ├── plugin/ # 10 OpenCode hook handlers + 52 hook composition +│ ├── plugin/ # 10 OpenCode hook handlers + 51 hook composition │ ├── plugin-handlers/ # 6-phase config loading pipeline │ └── openclaw/ # Bidirectional external integration (Discord/Telegram/webhook/command) ├── packages/ # 11 platform-specific compiled binaries (darwin/linux/windows, AVX2 + baseline variants) @@ -37,7 +37,7 @@ pluginModule.server(input, options) ├─→ loadPluginConfig() # JSONC parse → project/user merge → Zod validate → migrate ├─→ createManagers() # TmuxSessionManager, BackgroundManager, SkillMcpManager, ConfigHandler ├─→ createTools() # SkillContext + AvailableCategories + ToolRegistry (26 tools) - ├─→ createHooks() # 3-tier: Core(43) + Continuation(7) + Skill(2) = 52 hooks + ├─→ createHooks() # 3-tier: Core(42) + Continuation(7) + Skill(2) = 51 hooks └─→ createPluginInterface() # 10 OpenCode hook handlers → PluginInterface ``` @@ -104,7 +104,7 @@ Fields: agents (14 overridable, 21 fields each), categories (8 built-in + custom - **Test pattern**: Bun test (`bun:test`), co-located `*.test.ts`, given/when/then style (nested describe with `#given`/`#when`/`#then` prefixes or inline `// given` / `// when` / `// then` comments) - **CI test split**: `script/run-ci-tests.ts` auto-detects `mock.module()` usage, isolates those tests in separate processes - **Factory pattern**: `createXXX()` for all tools, hooks, agents -- **Hook tiers**: Session (24) → Tool-Guard (14) → Transform (5) → Continuation (7) → Skill (2) +- **Hook tiers**: Session (23) → Tool-Guard (14) → Transform (5) → Continuation (7) → Skill (2) - **Agent modes**: `primary` (respects UI model) vs `subagent` (own fallback chain) vs `all` - **Model resolution**: 4-step: override → category-default → provider-fallback → system-default - **Config format**: JSONC with comments, Zod v4 validation, snake_case keys diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 056c84342a6..c77a64b4848 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -5720,15 +5720,6 @@ }, "additionalProperties": false }, - "notification": { - "type": "object", - "properties": { - "force_enable": { - "type": "boolean" - } - }, - "additionalProperties": false - }, "model_capabilities": { "type": "object", "properties": { diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 582b5d8ba06..a195c5c3b78 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -29,6 +29,19 @@ After you install it, you can read this [overview guide](./overview.md) to under The published package and local binary are still `oh-my-opencode`. Inside `opencode.json`, the compatibility layer now prefers the plugin entry `oh-my-openagent`, while legacy `oh-my-opencode` entries still load with a warning. Plugin config loading recognizes both `oh-my-openagent.json[c]` and `oh-my-opencode.json[c]` during the transition. If you see a "Using legacy package name" warning from `bunx oh-my-opencode doctor`, update your `opencode.json` plugin entry from `"oh-my-opencode"` to `"oh-my-openagent"`. +## Session notification migration (KDCO) + +oh-my-opencode removed built-in `session-notification` handling. + +- Removed hook key: `session-notification` +- Removed config key: `notification.force_enable` + +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. + +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. + ## For LLM Agents > **IMPORTANT: Use `curl` to fetch this file, NOT WebFetch.** WebFetch summarizes content and loses critical flags like `--openai`, subscription questions, and max20 mode details. Always use: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 04f510b6da8..75cbc1ac484 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -25,7 +25,7 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr - [Tmux Integration](#tmux-integration) - [Git Master](#git-master) - [Comment Checker](#comment-checker) - - [Notification](#notification) + - [Session Alerts Migration](#session-alerts-migration) - [MCPs](#mcps) - [LSP](#lsp) - [Advanced](#advanced) @@ -509,7 +509,7 @@ Disable built-in hooks via `disabled_hooks`: { "disabled_hooks": ["comment-checker"] } ``` -Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` +Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `no-sisyphus-gpt`, `start-work`, `runtime-fallback` **Notes:** @@ -585,15 +585,18 @@ Customize the comment quality checker: } ``` -### Notification +### Session Alerts Migration -Force-enable session notifications: +Built-in `session-notification` support was removed from oh-my-opencode. -```json -{ "notification": { "force_enable": true } } -``` +- 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 + +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. -`force_enable` (`false`) - force session-notification even if external notification plugins are detected. +`background-notification` is unchanged because it handles parent-session reminder injection for background tasks, not notification transport. ### MCPs diff --git a/docs/reference/features.md b/docs/reference/features.md index 366554b6c93..848bb651283 100644 --- a/docs/reference/features.md +++ b/docs/reference/features.md @@ -789,10 +789,11 @@ Hooks intercept and modify behavior at key points in the agent lifecycle across | ---------------------------- | ------------------- | -------------------------------------------------------------------------------------------------- | | **auto-update-checker** | Event | Checks for new versions on session creation, shows startup toast with version and Sisyphus status. | | **background-notification** | Event | Notifies when background agent tasks complete. | -| **session-notification** | Event | OS notifications when agents go idle. Works on macOS, Linux, Windows. | | **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 provided by a bundled KDCO `opencode-notify` integration managed by oh-my-opencode. Separate external `kdco/notify` plugin installs are not required. + #### Task Management | Hook | Event | Description | diff --git a/package.json b/package.json index d00e0e2742b..6f5f08bc07d 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 build src/shared/bundled-notify-ownership.ts --outdir dist/shared --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 cdebb7e68cc..50c8489dc8d 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 00000000000..94a05b0c790 --- /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 00000000000..0b58a426349 --- /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 00000000000..641692b1b3d --- /dev/null +++ b/src/bundled-opencode-notify/index.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test" + +import bundledNotifyPlugin from "./index" + +interface TodoItem { + status: string +} + +type TodoResponseMode = { + todos?: TodoItem[] + throwError?: boolean +} + +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(todoMode: TodoResponseMode, notificationCommands: string[]) { + return { + $: createMockShellExecutor(notificationCommands), + client: { + session: { + todo: async () => { + if (todoMode.throwError) { + throw new Error("todo fetch failed") + } + + return { data: todoMode.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({ todos: [{ 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({ todos: [{ 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({ + todos: [ + { 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) + }) + + test("suppresses ready notification when todo fetch state is unknown", async () => { + // given + const notificationCommands: string[] = [] + const hooks = await bundledNotifyPlugin.server( + createPluginInput({ throwError: true }, notificationCommands), + ) + + // when + await hooks.event?.({ event: { type: "session.idle", properties: { sessionID: "session-4" } } }) + jest.advanceTimersByTime(1500) + await Promise.resolve() + await Promise.resolve() + + // then + expect(notificationCommands).toHaveLength(0) + }) +}) diff --git a/src/bundled-opencode-notify/index.ts b/src/bundled-opencode-notify/index.ts new file mode 100644 index 00000000000..511c20686c9 --- /dev/null +++ b/src/bundled-opencode-notify/index.ts @@ -0,0 +1,166 @@ +import type { Hooks, Plugin, PluginModule } from "@opencode-ai/plugin" +import { getSessionTodoState } 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 todoState = await getSessionTodoState(ctx, sessionID) + if (todoState !== "clear") 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/config/AGENTS.md b/src/config/AGENTS.md index d180669b382..bdd3b7d0f45 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -32 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional — omitted fields use plugin defaults. +28 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional — omitted fields use plugin defaults. ## SCHEMA TREE @@ -12,9 +12,10 @@ config/schema/ ├── oh-my-opencode-config.ts # ROOT: OhMyOpenCodeConfigSchema (composes all below) ├── agent-names.ts # BuiltinAgentNameSchema (11), OverridableAgentNameSchema (14) -├── agent-overrides.ts # AgentOverrideConfigSchema (21 fields per agent) +├── agent-definitions.ts # AgentDefinitionsConfigSchema (external files) +├── agent-overrides.ts # AgentOverrideConfigSchema (22 base fields; hephaestus adds allow_non_gpt_model) ├── categories.ts # 8 built-in + custom categories -├── hooks.ts # HookNameSchema (48 hooks) +├── hooks.ts # HookNameSchema (51 hooks) ├── skills.ts # SkillsConfigSchema (sources, paths, recursive) ├── commands.ts # BuiltinCommandNameSchema ├── experimental.ts # Feature flags (plugin_load_timeout_ms min 1000) @@ -25,9 +26,8 @@ config/schema/ ├── websearch.ts # provider: "exa" | "tavily" ├── claude-code.ts # CC compatibility settings ├── comment-checker.ts # AI comment detection config -├── notification.ts # OS notification settings ├── git-master.ts # commit_footer: boolean | string -├── browser-automation.ts # provider: playwright | agent-browser | playwright-cli +├── browser-automation.ts # provider: playwright | agent-browser | dev-browser | playwright-cli ├── background-task.ts # Concurrency limits per model/provider ├── fallback-models.ts # FallbackModelsConfigSchema ├── runtime-fallback.ts # RuntimeFallbackConfigSchema @@ -41,13 +41,15 @@ config/schema/ ``` -## ROOT SCHEMA FIELDS (32) +## ROOT SCHEMA FIELDS (34) -`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `hashline_edit`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `start_work`, `_migrations`, `model_fallback`, `model_capabilities`, `openclaw`, `mcp_env_allowlist` +`$schema`, `new_task_system_enabled`, `default_run_agent`, `agent_definitions`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `mcp_env_allowlist`, `hashline_edit`, `model_fallback`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `runtime_fallback`, `background_task`, `model_capabilities`, `openclaw`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `start_work`, `_migrations` -## AGENT OVERRIDE FIELDS (21) +## AGENT OVERRIDE FIELDS (22) -`model`, `variant`, `category`, `skills`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions` +`model`, `fallback_models`, `variant`, `category`, `skills`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`, `ultrawork`, `compaction` + +Note: `hephaestus` extends this base schema with `allow_non_gpt_model`. ## HOW TO ADD CONFIG diff --git a/src/config/schema.ts b/src/config/schema.ts index 04dd0b15b84..710e65d390c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -14,7 +14,6 @@ export * from "./schema/git-env-prefix" export * from "./schema/git-master" export * from "./schema/hooks" export * from "./schema/model-capabilities" -export * from "./schema/notification" export * from "./schema/oh-my-opencode-config" export * from "./schema/ralph-loop" export * from "./schema/runtime-fallback" diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index fea9c637195..44040c0707f 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -4,7 +4,6 @@ export const HookNameSchema = z.enum([ "todo-continuation-enforcer", "context-window-monitor", "session-recovery", - "session-notification", "comment-checker", "tool-output-truncator", "question-label-truncator", diff --git a/src/config/schema/notification.ts b/src/config/schema/notification.ts deleted file mode 100644 index 48b73da3514..00000000000 --- a/src/config/schema/notification.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from "zod" - -export const NotificationConfigSchema = z.object({ - /** Force enable session-notification even if external notification plugins are detected (default: false) */ - force_enable: z.boolean().optional(), -}) - -export type NotificationConfig = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index e62413d2683..b2a701786ef 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -12,7 +12,6 @@ import { CommentCheckerConfigSchema } from "./comment-checker" import { BuiltinCommandNameSchema } from "./commands" import { ExperimentalConfigSchema } from "./experimental" import { GitMasterConfigSchema } from "./git-master" -import { NotificationConfigSchema } from "./notification" import { OpenClawConfigSchema } from "./openclaw" import { ModelCapabilitiesConfigSchema } from "./model-capabilities" import { RalphLoopConfigSchema } from "./ralph-loop" @@ -60,7 +59,6 @@ export const OhMyOpenCodeConfigSchema = z.object({ */ runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(), background_task: BackgroundTaskConfigSchema.optional(), - notification: NotificationConfigSchema.optional(), model_capabilities: ModelCapabilitiesConfigSchema.optional(), openclaw: OpenClawConfigSchema.optional(), babysitting: BabysittingConfigSchema.optional(), diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 13533842433..94ee1909ab1 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -1,14 +1,14 @@ -# src/hooks/ — 52 Lifecycle Hooks +# src/hooks/ — 51 Lifecycle Hooks **Generated:** 2026-04-18 ## OVERVIEW -52 hooks across dedicated modules and standalone files. Three-tier composition: Core(43) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. +51 hooks across dedicated modules and standalone files. Three-tier composition: Core(42) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. ## HOOK TIERS -### Tier 1: Session Hooks (24) — `create-session-hooks.ts` +### Tier 1: Session Hooks (23) — `create-session-hooks.ts` ## STRUCTURE ``` hooks/ @@ -18,7 +18,7 @@ hooks/ ├── anthropic-effort/ # Reasoning effort level adjustment ├── auto-slash-command/ # Detects /command patterns ├── auto-update-checker/ # Plugin update check -├── background-notification/ # OS notification +├── background-notification/ # Background task reminder injection ├── category-skill-reminder/ # Reminds of category skills ├── claude-code-hooks/ # settings.json compat layer ├── comment-checker/ # Prevents AI slop @@ -67,7 +67,6 @@ hooks/ | contextWindowMonitor | session.idle | Track context window usage | | preemptiveCompaction | session.idle | Trigger compaction before limit | | sessionRecovery | session.error | Auto-retry on recoverable errors | -| sessionNotification | session.idle | OS notifications on completion | | thinkMode | chat.params | Model variant switching (extended thinking) | | anthropicContextWindowLimitRecovery | session.error | Multi-strategy context recovery (truncation, compaction) | | autoUpdateChecker | session.created | Check npm for plugin updates | @@ -164,7 +163,6 @@ Conditional rules injection from AGENTS.md, config, skill rules. Evaluates condi | context-window-monitor.ts | Track context window percentage | | preemptive-compaction.ts | Trigger compaction before hard limit | | tool-output-truncator.ts | Truncate tool output by token count | -| session-notification.ts + 4 helpers | OS notification on session completion | | empty-task-response-detector.ts | Detect empty/failed task responses | | session-todo-status.ts | Todo completion status tracking | diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 8fd15af2f3d..ed9a22e166b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,10 +1,6 @@ export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer"; export { createContextWindowMonitorHook } from "./context-window-monitor"; -export { createSessionNotification } from "./session-notification"; -export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender"; -export { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting"; export { hasIncompleteTodos } from "./session-todo-status"; -export { createIdleNotificationScheduler } from "./session-notification-scheduler"; export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery"; export { createCommentCheckerHooks } from "./comment-checker"; export { createToolOutputTruncatorHook } from "./tool-output-truncator"; diff --git a/src/hooks/session-notification-content.test.ts b/src/hooks/session-notification-content.test.ts deleted file mode 100644 index 39ae0660fc9..00000000000 --- a/src/hooks/session-notification-content.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -const { describe, expect, test } = require("bun:test") -import { buildReadyNotificationContent } from "./session-notification-content" - -describe("buildReadyNotificationContent", () => { - describe("#given session metadata and messages exist", () => { - test("#when ready notification content is built, #then it includes session title, last user query, and last assistant line", async () => { - const ctx = { - directory: "/tmp/test", - client: { - session: { - get: async () => ({ data: { title: "Bugfix session" } }), - messages: async () => ({ - data: [ - { - info: { role: "user" }, - parts: [{ type: "text", text: "Investigate\nthis flaky test" }], - }, - { - info: { role: "assistant" }, - parts: [{ type: "text", text: "First line\nFinal answer line" }], - }, - ], - }), - }, - }, - } - - const result = await buildReadyNotificationContent(ctx, { - sessionID: "ses_123", - baseTitle: "OpenCode", - baseMessage: "Agent is ready for input", - }) - - expect(result).toEqual({ - title: "OpenCode · Bugfix session", - message: "Agent is ready for input\nUser: Investigate this flaky test\nAssistant: Final answer line", - }) - }) - }) - - describe("#given session APIs do not provide rich data", () => { - test("#when ready notification content is built, #then it falls back to session id and the base message", async () => { - const ctx = { - directory: "/tmp/test", - client: { - session: { - get: async () => ({ data: {} }), - messages: async () => ({ data: [] }), - }, - }, - } - - const result = await buildReadyNotificationContent(ctx, { - sessionID: "ses_fallback", - baseTitle: "OpenCode", - baseMessage: "Agent is ready for input", - }) - - expect(result).toEqual({ - title: "OpenCode · ses_fallback", - message: "Agent is ready for input", - }) - }) - }) -}) - -export {} diff --git a/src/hooks/session-notification-content.ts b/src/hooks/session-notification-content.ts deleted file mode 100644 index eaf33180d01..00000000000 --- a/src/hooks/session-notification-content.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { normalizeSDKResponse } from "../shared" - -type ReadyNotificationContext = { - client: { - session: { - get?: (input: { path: { id: string } }) => Promise - messages?: (input: { path: { id: string }; query: { directory: string } }) => Promise - } - } - directory: string -} - -type SessionInfo = { - title?: string -} - -type SessionMessagePart = { - type?: string - text?: string -} - -type SessionMessage = { - info?: { - role?: string - error?: unknown - } - parts?: SessionMessagePart[] -} - -type ReadyNotificationInput = { - sessionID: string - baseTitle: string - baseMessage: string -} - -function extractMessageText(message: SessionMessage | undefined): string { - return (message?.parts ?? []) - .filter((part) => part.type === "text" && typeof part.text === "string") - .map((part) => part.text?.trim() ?? "") - .filter(Boolean) - .join("\n") -} - -function collapseWhitespace(text: string): string { - return text - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean) - .join(" ") -} - -function getLastNonEmptyLine(text: string): string { - const lines = text - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean) - - return lines.at(-1) ?? "" -} - -function findLastMessage(messages: SessionMessage[], role: "user" | "assistant"): SessionMessage | undefined { - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index] - if (message.info?.role !== role) continue - if (role === "assistant" && message.info?.error) continue - if (!extractMessageText(message)) continue - return message - } - - return undefined -} - -async function readSessionTitle( - ctx: ReadyNotificationContext, - sessionID: string, -): Promise { - if (typeof ctx.client.session.get !== "function") { - return sessionID - } - - try { - const response = await ctx.client.session.get({ path: { id: sessionID } }) - const sessionInfo = normalizeSDKResponse(response, null as SessionInfo | null, { - preferResponseOnMissingData: true, - }) - - if (sessionInfo?.title && sessionInfo.title.trim().length > 0) { - return sessionInfo.title.trim() - } - } catch { - } - - return sessionID -} - -async function readSessionMessages( - ctx: ReadyNotificationContext, - sessionID: string, -): Promise { - if (typeof ctx.client.session.messages !== "function") { - return [] - } - - try { - const response = await ctx.client.session.messages({ - path: { id: sessionID }, - query: { directory: ctx.directory }, - }) - - const messages = normalizeSDKResponse(response, [] as SessionMessage[], { - preferResponseOnMissingData: true, - }) - - return Array.isArray(messages) ? messages : [] - } catch { - return [] - } -} - -export async function buildReadyNotificationContent( - ctx: ReadyNotificationContext, - input: ReadyNotificationInput, -): Promise<{ title: string; message: string }> { - const [sessionTitle, messages] = await Promise.all([ - readSessionTitle(ctx, input.sessionID), - readSessionMessages(ctx, input.sessionID), - ]) - - const lastUserText = collapseWhitespace(extractMessageText(findLastMessage(messages, "user"))) - const lastAssistantLine = getLastNonEmptyLine( - extractMessageText(findLastMessage(messages, "assistant")), - ) - - const detailLines = [ - lastUserText ? `User: ${lastUserText}` : "", - lastAssistantLine ? `Assistant: ${lastAssistantLine}` : "", - ].filter(Boolean) - - return { - title: `${input.baseTitle} · ${sessionTitle}`, - message: detailLines.length > 0 - ? [input.baseMessage, ...detailLines].join("\n") - : input.baseMessage, - } -} diff --git a/src/hooks/session-notification-event-properties.ts b/src/hooks/session-notification-event-properties.ts deleted file mode 100644 index b51edf81b4e..00000000000 --- a/src/hooks/session-notification-event-properties.ts +++ /dev/null @@ -1,51 +0,0 @@ -type EventProperties = Record | undefined - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function getEventInfo(properties: EventProperties): Record | undefined { - const info = properties?.info - return isRecord(info) ? info : undefined -} - -export function getSessionID(properties: EventProperties): string | undefined { - const sessionID = properties?.sessionID - if (typeof sessionID === "string" && sessionID.length > 0) return sessionID - - const sessionId = properties?.sessionId - if (typeof sessionId === "string" && sessionId.length > 0) return sessionId - - const info = getEventInfo(properties) - const infoSessionID = info?.sessionID - if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID - - const infoSessionId = info?.sessionId - if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId - - return undefined -} - -export function getEventToolName(properties: EventProperties): string | undefined { - const tool = properties?.tool - if (typeof tool === "string" && tool.length > 0) return tool - - const name = properties?.name - if (typeof name === "string" && name.length > 0) return name - - return undefined -} - -export function getQuestionText(properties: EventProperties): string { - const args = properties?.args - if (!isRecord(args)) return "" - - const questions = args.questions - if (!Array.isArray(questions) || questions.length === 0) return "" - - const firstQuestion = questions[0] - if (!isRecord(firstQuestion)) return "" - - const questionText = firstQuestion.question - return typeof questionText === "string" ? questionText : "" -} diff --git a/src/hooks/session-notification-formatting.ts b/src/hooks/session-notification-formatting.ts deleted file mode 100644 index c39cb30d8d7..00000000000 --- a/src/hooks/session-notification-formatting.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function escapeAppleScriptText(input: string): string { - return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"') -} - -export function escapePowerShellSingleQuotedText(input: string): string { - return input.replace(/'/g, "''") -} - -export function buildWindowsToastScript(title: string, message: string): string { - const psTitle = escapePowerShellSingleQuotedText(title) - const psMessage = escapePowerShellSingleQuotedText(message) - - return ` -[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null -$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) -$RawXml = [xml] $Template.GetXml() -($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null -($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null -$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument -$SerializedXml.LoadXml($RawXml.OuterXml) -$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml) -$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode') -$Notifier.Show($Toast) -`.trim().replace(/\n/g, "; ") -} diff --git a/src/hooks/session-notification-init.ts b/src/hooks/session-notification-init.ts deleted file mode 100644 index 3dab42ea6d7..00000000000 --- a/src/hooks/session-notification-init.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Platform } from "./session-notification-sender" -import * as sessionNotificationSender from "./session-notification-sender" -import { startBackgroundCheck } from "./session-notification-utils" - -export function createSessionNotificationInit() { - let platform: Platform | null = null - let defaultSoundPath: string | null = null - let started = false - - function initialize(): { platform: Platform; defaultSoundPath: string } { - if (!platform) { - platform = sessionNotificationSender.detectPlatform() - } - if (!defaultSoundPath) { - defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(platform) - } - if (!started) { - startBackgroundCheck(platform) - started = true - } - - return { - platform, - defaultSoundPath, - } - } - - return { - initialize, - } -} diff --git a/src/hooks/session-notification-input-needed.test.ts b/src/hooks/session-notification-input-needed.test.ts deleted file mode 100644 index f85d9154de7..00000000000 --- a/src/hooks/session-notification-input-needed.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test") - -const { createSessionNotification } = require("./session-notification") -const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state") -const utils = require("./session-notification-utils") -const sender = require("./session-notification-sender") - -describe("session-notification input-needed events", () => { - let notificationCalls: string[] - - function createMockPluginInput() { - return { - $: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - - if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) { - notificationCalls.push(cmdStr) - } - - return { stdout: "", stderr: "", exitCode: 0 } - }, - client: { - session: { - todo: async () => ({ data: [] }), - }, - }, - directory: "/tmp/test", - } - } - - beforeEach(() => { - _resetForTesting() - notificationCalls = [] - - spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") - spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") - spyOn(utils, "getPowershellPath").mockResolvedValue("powershell") - spyOn(utils, "startBackgroundCheck").mockImplementation(() => {}) - spyOn(sender, "detectPlatform").mockReturnValue("darwin") - spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx: unknown, _platform: unknown, _title: unknown, message: string) => { - notificationCalls.push(message) - }) - }) - - afterEach(() => { - subagentSessions.clear() - _resetForTesting() - }) - - test("sends question notification when question tool asks for input", async () => { - const sessionID = "main-question" - setMainSession(sessionID) - const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false }) - - await hook({ - event: { - type: "tool.execute.before", - properties: { - sessionID, - tool: "question", - args: { - questions: [ - { - question: "Which branch should we use?", - options: [{ label: "main" }, { label: "dev" }], - }, - ], - }, - }, - }, - }) - - expect(notificationCalls).toHaveLength(1) - expect(notificationCalls[0]).toContain("Agent is asking a question") - }) - - test("sends permission notification for permission events", async () => { - const sessionID = "main-permission" - setMainSession(sessionID) - const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false }) - - await hook({ - event: { - type: "permission.asked", - properties: { - sessionID, - }, - }, - }) - - expect(notificationCalls).toHaveLength(1) - expect(notificationCalls[0]).toContain("Agent needs permission to continue") - }) - - test("lazily detects platform and starts background checks on first idle event", async () => { - const sessionID = "main-idle" - setMainSession(sessionID) - - const detectPlatformSpy = spyOn(sender, "detectPlatform") - detectPlatformSpy.mockReturnValue("darwin") - - const getDefaultSoundPathSpy = spyOn(sender, "getDefaultSoundPath") - getDefaultSoundPathSpy.mockReturnValue("/System/Library/Sounds/Glass.aiff") - - const startBackgroundCheckSpy = spyOn(utils, "startBackgroundCheck") - startBackgroundCheckSpy.mockImplementation(() => {}) - - // given - const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false }) - - // when - await hook({ - event: { - type: "session.idle", - properties: { - sessionID, - }, - }, - }) - - // then - expect(detectPlatformSpy).toHaveBeenCalledTimes(1) - expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1) - expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1) - - // when - await hook({ - event: { - type: "session.idle", - properties: { - sessionID, - }, - }, - }) - - // then - expect(detectPlatformSpy).toHaveBeenCalledTimes(1) - expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1) - expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1) - }) -}) - -export {} diff --git a/src/hooks/session-notification-scheduler.ts b/src/hooks/session-notification-scheduler.ts deleted file mode 100644 index afea12c7f14..00000000000 --- a/src/hooks/session-notification-scheduler.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import type { Platform } from "./session-notification-sender" - -type SessionNotificationConfig = { - playSound: boolean - soundPath: string - idleConfirmationDelay: number - skipIfIncompleteTodos: boolean - maxTrackedSessions: number - /** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */ - activityGracePeriodMs?: number -} - -export function createIdleNotificationScheduler(options: { - ctx: PluginInput - platform: Platform - config: SessionNotificationConfig - hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise - send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise - playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise -}) { - const notifiedSessions = new Set() - const pendingTimers = new Map>() - const sessionActivitySinceIdle = new Set() - const notificationVersions = new Map() - const executingNotifications = new Set() - const scheduledAt = new Map() - - const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100 - - function cleanupOldSessions(): void { - const maxSessions = options.config.maxTrackedSessions - if (notifiedSessions.size > maxSessions) { - const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions) - sessionsToRemove.forEach((id) => { - notifiedSessions.delete(id) - }) - } - if (sessionActivitySinceIdle.size > maxSessions) { - const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions) - sessionsToRemove.forEach((id) => { - sessionActivitySinceIdle.delete(id) - }) - } - if (notificationVersions.size > maxSessions) { - const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions) - sessionsToRemove.forEach((id) => { - notificationVersions.delete(id) - }) - } - if (executingNotifications.size > maxSessions) { - const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions) - sessionsToRemove.forEach((id) => { - executingNotifications.delete(id) - }) - } - if (scheduledAt.size > maxSessions) { - const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions) - sessionsToRemove.forEach((id) => { - scheduledAt.delete(id) - }) - } - } - - function cancelPendingNotification(sessionID: string): void { - const timer = pendingTimers.get(sessionID) - if (timer) { - clearTimeout(timer) - pendingTimers.delete(sessionID) - } - scheduledAt.delete(sessionID) - sessionActivitySinceIdle.add(sessionID) - notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1) - } - - function markSessionActivity(sessionID: string): void { - const scheduledTime = scheduledAt.get(sessionID) - if ( - activityGracePeriodMs > 0 && - scheduledTime !== undefined && - Date.now() - scheduledTime <= activityGracePeriodMs - ) { - return - } - - cancelPendingNotification(sessionID) - if (!executingNotifications.has(sessionID)) { - notifiedSessions.delete(sessionID) - } - } - - async function executeNotification(sessionID: string, version: number): Promise { - if (executingNotifications.has(sessionID)) { - pendingTimers.delete(sessionID) - scheduledAt.delete(sessionID) - return - } - - if (notificationVersions.get(sessionID) !== version) { - pendingTimers.delete(sessionID) - scheduledAt.delete(sessionID) - return - } - - if (sessionActivitySinceIdle.has(sessionID)) { - sessionActivitySinceIdle.delete(sessionID) - pendingTimers.delete(sessionID) - scheduledAt.delete(sessionID) - return - } - - if (notifiedSessions.has(sessionID)) { - pendingTimers.delete(sessionID) - scheduledAt.delete(sessionID) - return - } - - executingNotifications.add(sessionID) - try { - if (options.config.skipIfIncompleteTodos) { - const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID) - if (notificationVersions.get(sessionID) !== version) { - return - } - if (hasPendingWork) return - } - - if (notificationVersions.get(sessionID) !== version) { - return - } - - if (sessionActivitySinceIdle.has(sessionID)) { - sessionActivitySinceIdle.delete(sessionID) - return - } - - notifiedSessions.add(sessionID) - - await options.send(options.ctx, options.platform, sessionID) - - if (options.config.playSound && options.config.soundPath) { - await options.playSound(options.ctx, options.platform, options.config.soundPath) - } - } finally { - executingNotifications.delete(sessionID) - pendingTimers.delete(sessionID) - scheduledAt.delete(sessionID) - if (sessionActivitySinceIdle.has(sessionID)) { - notifiedSessions.delete(sessionID) - sessionActivitySinceIdle.delete(sessionID) - } - } - } - - function scheduleIdleNotification(sessionID: string): void { - if (notifiedSessions.has(sessionID)) return - if (pendingTimers.has(sessionID)) return - if (executingNotifications.has(sessionID)) return - - sessionActivitySinceIdle.delete(sessionID) - scheduledAt.set(sessionID, Date.now()) - - const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1 - notificationVersions.set(sessionID, currentVersion) - - const timer = setTimeout(() => { - executeNotification(sessionID, currentVersion) - }, options.config.idleConfirmationDelay) - - pendingTimers.set(sessionID, timer) - cleanupOldSessions() - } - - function deleteSession(sessionID: string): void { - cancelPendingNotification(sessionID) - notifiedSessions.delete(sessionID) - sessionActivitySinceIdle.delete(sessionID) - notificationVersions.delete(sessionID) - executingNotifications.delete(sessionID) - scheduledAt.delete(sessionID) - } - - return { - markSessionActivity, - scheduleIdleNotification, - deleteSession, - } -} diff --git a/src/hooks/session-notification-sender.test.ts b/src/hooks/session-notification-sender.test.ts deleted file mode 100644 index 2747109cf9d..00000000000 --- a/src/hooks/session-notification-sender.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test" -import * as sender from "./session-notification-sender" -import * as utils from "./session-notification-utils" -import type { PluginInput } from "@opencode-ai/plugin" - - - -function createShellPromise(handler: (cmdStr: string) => void) { - return (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - handler(cmdStr) - - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => Promise - nothrow: () => Promise & { quiet: () => Promise } - } - promise.quiet = () => promise - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => p - p.nothrow = () => p - return p - } - return promise - } -} - -function createThrowingShellPromise(shouldThrow: (cmdStr: string) => boolean) { - return (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - - if (shouldThrow(cmdStr)) { - const err = Object.assign(new Error("command failed"), result) - const rejectedPromise = Promise.reject(err) as Promise & { - quiet: () => Promise - nothrow: () => Promise & { quiet: () => Promise } - } - rejectedPromise.quiet = () => rejectedPromise - rejectedPromise.nothrow = () => { - const p = Promise.resolve(result) as typeof rejectedPromise - p.quiet = () => p - p.nothrow = () => p - return p - } - return rejectedPromise - } - - const promise = Promise.resolve(result) as Promise & { - quiet: () => Promise - nothrow: () => Promise & { quiet: () => Promise } - } - promise.quiet = () => promise - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => p - p.nothrow = () => p - return p - } - return promise - } -} - -describe("session-notification-sender", () => { - beforeEach(() => { - jest.restoreAllMocks() - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") - spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") - spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") - spyOn(utils, "getPowershellPath").mockResolvedValue("powershell") - spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay") - spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay") - spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay") - }) - - describe("#given sendSessionNotification", () => { - describe("#when calling ctx.$ for notifications", () => { - test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => { - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => Promise - nothrow: () => typeof promise - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => promise - return promise - }, - } as unknown as PluginInput - - await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") - - expect(quietCalls.length).toBeGreaterThanOrEqual(1) - expect(quietCalls[0]).toContain("terminal-notifier") - }) - - test("#then should call .quiet() on osascript fallback", async () => { - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) - - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message") - - expect(quietCalls.length).toBeGreaterThanOrEqual(1) - expect(quietCalls[0]).toContain("osascript") - }) - - test("#then should call .quiet() on linux notify-send", async () => { - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.sendSessionNotification(mockCtx, "linux", "Test", "Message") - - expect(quietCalls.length).toBe(1) - expect(quietCalls[0]).toContain("notify-send") - }) - - test("#then should call .quiet() on win32 powershell", async () => { - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message") - - expect(quietCalls.length).toBe(1) - expect(quietCalls[0]).toContain("powershell") - }) - }) - }) - - describe("#given playSessionNotificationSound", () => { - describe("#when calling ctx.$ for sound playback", () => { - test("#then should call .quiet() on darwin afplay", async () => { - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff") - - expect(quietCalls.length).toBe(1) - expect(quietCalls[0]).toContain("afplay") - }) - - test("#then should call .quiet() on linux paplay", async () => { - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga") - - expect(quietCalls.length).toBe(1) - expect(quietCalls[0]).toContain("paplay") - }) - - test("#then should call .quiet() on linux aplay fallback", async () => { - spyOn(utils, "getPaplayPath").mockResolvedValue(null) - - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga") - - expect(quietCalls.length).toBe(1) - expect(quietCalls[0]).toContain("aplay") - }) - - test("#then should call .quiet() on win32 powershell sound", async () => { - const quietCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray, ...values: unknown[]) => { - const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 } - const promise = Promise.resolve(result) as Promise & { - quiet: () => typeof promise - nothrow: () => typeof promise & { quiet: () => typeof promise } - } - promise.quiet = () => { - quietCalls.push(cmdStr) - return promise - } - promise.nothrow = () => { - const p = Promise.resolve(result) as typeof promise - p.quiet = () => { - quietCalls.push(cmdStr) - return p - } - p.nothrow = () => p - return p - } - return promise - }, - } as unknown as PluginInput - - await sender.playSessionNotificationSound(mockCtx, "win32", "C:\\sound.wav") - - expect(quietCalls.length).toBe(1) - expect(quietCalls[0]).toContain("powershell") - }) - }) - }) -}) diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts deleted file mode 100644 index 504385ffa93..00000000000 --- a/src/hooks/session-notification-sender.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { platform } from "os" -import { - getOsascriptPath, - getNotifySendPath, - getPowershellPath, - getAfplayPath, - getPaplayPath, - getAplayPath, - getTerminalNotifierPath, -} from "./session-notification-utils" -import { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting" - -export type Platform = "darwin" | "linux" | "win32" | "unsupported" - -export function detectPlatform(): Platform { - const detected = platform() - if (detected === "darwin" || detected === "linux" || detected === "win32") return detected - return "unsupported" -} - -export function getDefaultSoundPath(platform: Platform): string { - switch (platform) { - case "darwin": - return "/System/Library/Sounds/Glass.aiff" - case "linux": - return "/usr/share/sounds/freedesktop/stereo/complete.oga" - case "win32": - return "C:\\Windows\\Media\\notify.wav" - default: - return "" - } -} - -export async function sendSessionNotification( - ctx: PluginInput, - platform: Platform, - title: string, - message: string -): Promise { - switch (platform) { - case "darwin": { - // Try terminal-notifier first - deterministic click-to-focus - const terminalNotifierPath = await getTerminalNotifierPath() - if (terminalNotifierPath) { - const bundleId = process.env.__CFBundleIdentifier - try { - if (bundleId) { - await ctx.$`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}`.quiet() - } else { - await ctx.$`${terminalNotifierPath} -title ${title} -message ${message}`.quiet() - } - break - } catch { - } - } - - // Fallback: osascript (click may open Finder instead of terminal) - const osascriptPath = await getOsascriptPath() - if (!osascriptPath) return - - const escapedTitle = escapeAppleScriptText(title) - const escapedMessage = escapeAppleScriptText(message) - await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.nothrow().quiet() - break - } - case "linux": { - const notifySendPath = await getNotifySendPath() - if (!notifySendPath) return - - await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.nothrow().quiet() - break - } - case "win32": { - const powershellPath = await getPowershellPath() - if (!powershellPath) return - - const toastScript = buildWindowsToastScript(title, message) - await ctx.$`${powershellPath} -Command ${toastScript}`.nothrow().quiet() - break - } - } -} - -export async function playSessionNotificationSound( - ctx: PluginInput, - platform: Platform, - soundPath: string -): Promise { - switch (platform) { - case "darwin": { - const afplayPath = await getAfplayPath() - if (!afplayPath) return - ctx.$`${afplayPath} ${soundPath}`.nothrow().quiet() - break - } - case "linux": { - const paplayPath = await getPaplayPath() - if (paplayPath) { - ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet() - } else { - const aplayPath = await getAplayPath() - if (aplayPath) { - ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet() - } - } - break - } - case "win32": { - const powershellPath = await getPowershellPath() - if (!powershellPath) return - const escaped = escapePowerShellSingleQuotedText(soundPath) - ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`.nothrow().quiet() - break - } - } -} diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts deleted file mode 100644 index cf4ca06ead8..00000000000 --- a/src/hooks/session-notification-utils.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { log } from "../shared/logger" - -declare const Bun: { - which(commandName: string): string | null -} - -type Platform = "darwin" | "linux" | "win32" | "unsupported" - -async function findCommand(commandName: string): Promise { - try { - return Bun.which(commandName) - } catch (error) { - log("[session-notification] failed to resolve command path", { - commandName, - error: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -function logBackgroundCheckError(commandName: string, error: unknown): void { - log("[session-notification] background command check failed", { - commandName, - error: error instanceof Error ? error.message : String(error), - }) -} - -function createCommandFinder(commandName: string): () => Promise { - let cachedPath: string | null = null - let pending: Promise | null = null - - return async () => { - if (cachedPath !== null) return cachedPath - if (pending) return pending - - pending = (async () => { - const path = await findCommand(commandName) - cachedPath = path - return path - })() - - return pending - } -} - -export const getNotifySendPath = createCommandFinder("notify-send") -export const getOsascriptPath = createCommandFinder("osascript") -export const getPowershellPath = createCommandFinder("powershell") -export const getAfplayPath = createCommandFinder("afplay") -export const getPaplayPath = createCommandFinder("paplay") -export const getAplayPath = createCommandFinder("aplay") -export const getTerminalNotifierPath = createCommandFinder("terminal-notifier") - -export function startBackgroundCheck(platform: Platform): void { - if (platform === "darwin") { - getOsascriptPath().catch((error) => { - logBackgroundCheckError("osascript", error) - }) - getAfplayPath().catch((error) => { - logBackgroundCheckError("afplay", error) - }) - getTerminalNotifierPath().catch((error) => { - logBackgroundCheckError("terminal-notifier", error) - }) - } else if (platform === "linux") { - getNotifySendPath().catch((error) => { - logBackgroundCheckError("notify-send", error) - }) - getPaplayPath().catch((error) => { - logBackgroundCheckError("paplay", error) - }) - getAplayPath().catch((error) => { - logBackgroundCheckError("aplay", error) - }) - } else if (platform === "win32") { - getPowershellPath().catch((error) => { - logBackgroundCheckError("powershell", error) - }) - } -} diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts deleted file mode 100644 index 11a04b03b14..00000000000 --- a/src/hooks/session-notification.test.ts +++ /dev/null @@ -1,637 +0,0 @@ -import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test" -import { createSessionNotification } from "./session-notification" -import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state" -import * as utils from "./session-notification-utils" -import * as sender from "./session-notification-sender" - -const originalSetTimeout = globalThis.setTimeout -const originalClearTimeout = globalThis.clearTimeout -const originalDateNow = Date.now - -describe("session-notification", () => { - let notificationCalls: string[] - - function createMockPluginInput() { - return { - $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { - // given - track notification commands (osascript, notify-send, powershell) - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - - if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) { - notificationCalls.push(cmdStr) - } - return { stdout: "", stderr: "", exitCode: 0 } - }, - client: { - session: { - todo: async () => ({ data: [] }), - }, - }, - directory: "/tmp/test", - } as any - } - - beforeEach(() => { - jest.useRealTimers() - globalThis.setTimeout = originalSetTimeout - globalThis.clearTimeout = originalClearTimeout - Date.now = originalDateNow - _resetForTesting() - notificationCalls = [] - - spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") - spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send") - spyOn(utils, "getPowershellPath").mockResolvedValue("powershell") - spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay") - spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay") - spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay") - spyOn(utils, "startBackgroundCheck").mockImplementation(() => {}) - spyOn(sender, "detectPlatform").mockReturnValue("darwin") - spyOn(sender, "sendSessionNotification").mockImplementation( - async ( - _ctx: Parameters[0], - _platform: Parameters[1], - _title: Parameters[2], - message: Parameters[3] - ) => { - notificationCalls.push(message) - } - ) - }) - - afterEach(() => { - // given - cleanup after each test - jest.useRealTimers() - globalThis.setTimeout = originalSetTimeout - globalThis.clearTimeout = originalClearTimeout - Date.now = originalDateNow - subagentSessions.clear() - _resetForTesting() - }) - - test("should not trigger notification for subagent session", async () => { - // given - a subagent session exists - const subagentSessionID = "subagent-123" - subagentSessions.add(subagentSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 0, - }) - - // when - subagent session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID: subagentSessionID }, - }, - }) - - // Wait for any pending timers - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - notification should NOT be sent - expect(notificationCalls).toHaveLength(0) - }) - - test("should not trigger notification when mainSessionID is set and session is not main", async () => { - // given - main session is set, but a different session goes idle - const mainSessionID = "main-123" - const otherSessionID = "other-456" - setMainSession(mainSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 0, - }) - - // when - non-main session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID: otherSessionID }, - }, - }) - - // Wait for any pending timers - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - notification should NOT be sent - expect(notificationCalls).toHaveLength(0) - }) - - test("should trigger notification for main session when idle", async () => { - // given - main session is set - const mainSessionID = "main-789" - setMainSession(mainSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 10, - skipIfIncompleteTodos: false, - enforceMainSessionFilter: false, - }) - - // when - main session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID: mainSessionID }, - }, - }) - - // Wait for idle confirmation delay + buffer - await new Promise((resolve) => setTimeout(resolve, 100)) - - // then - notification should be sent - expect(notificationCalls.length).toBeGreaterThanOrEqual(1) - }) - - test("should skip notification for subagent even when mainSessionID is set", async () => { - // given - both mainSessionID and subagent session exist - const mainSessionID = "main-999" - const subagentSessionID = "subagent-888" - setMainSession(mainSessionID) - subagentSessions.add(subagentSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 0, - }) - - // when - subagent session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID: subagentSessionID }, - }, - }) - - // Wait for any pending timers - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - notification should NOT be sent (subagent check takes priority) - expect(notificationCalls).toHaveLength(0) - }) - - test("should handle subagentSessions and mainSessionID checks in correct order", async () => { - // given - main session and subagent session exist - const mainSessionID = "main-111" - const subagentSessionID = "subagent-222" - const unknownSessionID = "unknown-333" - setMainSession(mainSessionID) - subagentSessions.add(subagentSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 0, - }) - - // when - subagent session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID: subagentSessionID }, - }, - }) - - // when - unknown session goes idle (not main, not in subagentSessions) - await hook({ - event: { - type: "session.idle", - properties: { sessionID: unknownSessionID }, - }, - }) - - // Wait for any pending timers - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check) - expect(notificationCalls).toHaveLength(0) - }) - - test("should cancel pending notification on session activity", async () => { - // given - main session is set - const mainSessionID = "main-cancel" - setMainSession(mainSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 100, - skipIfIncompleteTodos: false, - activityGracePeriodMs: 0, - }) - - // when - session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID: mainSessionID }, - }, - }) - - // when - activity happens before delay completes - await hook({ - event: { - type: "tool.execute.before", - properties: { sessionID: mainSessionID }, - }, - }) - - // Wait for original delay to pass - await new Promise((resolve) => setTimeout(resolve, 150)) - - // then - notification should NOT be sent (cancelled by activity) - expect(notificationCalls).toHaveLength(0) - }) - - test("should handle session.created event without notification", async () => { - // given - a new session is created - const hook = createSessionNotification(createMockPluginInput(), {}) - - // when - session.created event fires - await hook({ - event: { - type: "session.created", - properties: { - info: { id: "new-session", title: "Test Session" }, - }, - }, - }) - - // Wait for any pending timers - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - no notification should be triggered - expect(notificationCalls).toHaveLength(0) - }) - - test("should handle session.deleted event and cleanup state", async () => { - // given - a session exists - const hook = createSessionNotification(createMockPluginInput(), {}) - - // when - session.deleted event fires - await hook({ - event: { - type: "session.deleted", - properties: { - info: { id: "deleted-session" }, - }, - }, - }) - - // Wait for any pending timers - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - no notification should be triggered - expect(notificationCalls).toHaveLength(0) - }) - - test("should mark session activity on message.updated event", async () => { - // given - main session is set - const mainSessionID = "main-message" - setMainSession(mainSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 50, - skipIfIncompleteTodos: false, - activityGracePeriodMs: 0, - }) - - // when - session goes idle, then message.updated fires - await hook({ - event: { - type: "session.idle", - properties: { sessionID: mainSessionID }, - }, - }) - - await hook({ - event: { - type: "message.updated", - properties: { - info: { sessionID: mainSessionID, role: "user", finish: false }, - }, - }, - }) - - // Wait for idle delay to pass - await new Promise((resolve) => setTimeout(resolve, 100)) - - // then - notification should NOT be sent (activity cancelled it) - expect(notificationCalls).toHaveLength(0) - }) - - test("should mark session activity on tool.execute.before event", async () => { - // given - main session is set - const mainSessionID = "main-tool" - setMainSession(mainSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 50, - skipIfIncompleteTodos: false, - activityGracePeriodMs: 0, - }) - - // when - session goes idle, then tool.execute.before fires - await hook({ - event: { - type: "session.idle", - properties: { sessionID: mainSessionID }, - }, - }) - - await hook({ - event: { - type: "tool.execute.before", - properties: { sessionID: mainSessionID }, - }, - }) - - // Wait for idle delay to pass - await new Promise((resolve) => setTimeout(resolve, 100)) - - // then - notification should NOT be sent (activity cancelled it) - expect(notificationCalls).toHaveLength(0) - }) - - test("should not send duplicate notification for same session", async () => { - // given - main session is set - const mainSessionID = "main-dup" - setMainSession(mainSessionID) - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 10, - skipIfIncompleteTodos: false, - enforceMainSessionFilter: false, - }) - - // when - session goes idle twice - await hook({ - event: { - type: "session.idle", - properties: { sessionID: mainSessionID }, - }, - }) - - // Wait for first notification - await new Promise((resolve) => setTimeout(resolve, 50)) - - await hook({ - event: { - type: "session.idle", - properties: { sessionID: mainSessionID }, - }, - }) - - // Wait for second potential notification - await new Promise((resolve) => setTimeout(resolve, 50)) - - // then - only one notification should be sent - expect(notificationCalls).toHaveLength(1) - }) - - function createSenderMockCtx() { - const notifyCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray | string, ...values: any[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") - notifyCalls.push(cmdStr) - const result = { stdout: "", stderr: "", exitCode: 0 } - const promise = Promise.resolve(result) as any - promise.quiet = () => promise - promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p } - return promise - }, - } as any - return { mockCtx, notifyCalls } - } - - test("should use terminal-notifier with -activate when available on darwin", async () => { - // given - terminal-notifier is available and __CFBundleIdentifier is set - spyOn(sender, "sendSessionNotification").mockRestore() - const { mockCtx, notifyCalls } = createSenderMockCtx() - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") - const originalEnv = process.env.__CFBundleIdentifier - process.env.__CFBundleIdentifier = "com.mitchellh.ghostty" - - try { - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - - // then - notification uses terminal-notifier with -activate flag - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeDefined() - expect(tnCall).toContain("-activate") - expect(tnCall).toContain("com.mitchellh.ghostty") - } finally { - if (originalEnv !== undefined) { - process.env.__CFBundleIdentifier = originalEnv - } else { - delete process.env.__CFBundleIdentifier - } - } - }) - - test("should fall back to osascript when terminal-notifier is not available", async () => { - // given - terminal-notifier is NOT available - spyOn(sender, "sendSessionNotification").mockRestore() - const { mockCtx, notifyCalls } = createSenderMockCtx() - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) - spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") - - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - - // then - notification uses osascript (fallback) - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const osascriptCall = notifyCalls.find(c => c.includes("osascript")) - expect(osascriptCall).toBeDefined() - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeUndefined() - }) - - test("should fall back to osascript when terminal-notifier execution fails", async () => { - // given - terminal-notifier exists but invocation fails - spyOn(sender, "sendSessionNotification").mockRestore() - const notifyCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray | string, ...values: unknown[]) => { - const cmdStr = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") - notifyCalls.push(cmdStr) - - if (cmdStr.includes("terminal-notifier")) { - const err = Object.assign(new Error("terminal-notifier failed"), { stdout: "", stderr: "", exitCode: 1 }) - const rejected = Promise.reject(err) as any - rejected.quiet = () => rejected - rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p } - return rejected - } - - const result = { stdout: "", stderr: "", exitCode: 0 } - const promise = Promise.resolve(result) as any - promise.quiet = () => promise - promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p } - return promise - }, - } as any - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") - spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") - - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - - // then - osascript fallback should be attempted after terminal-notifier failure - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - const osascriptCall = notifyCalls.find(c => c.includes("osascript")) - expect(tnCall).toBeDefined() - expect(osascriptCall).toBeDefined() - }) - - test("should invoke terminal-notifier without array interpolation", async () => { - // given - shell interpolation rejects array values - spyOn(sender, "sendSessionNotification").mockRestore() - const notifyCalls: string[] = [] - const mockCtx = { - $: (cmd: TemplateStringsArray | string, ...values: unknown[]) => { - if (values.some(Array.isArray)) { - const err = Object.assign(new Error("array interpolation unsupported"), { stdout: "", stderr: "", exitCode: 1 }) - const rejected = Promise.reject(err) as any - rejected.quiet = () => rejected - rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p } - return rejected - } - - const commandString = typeof cmd === "string" - ? cmd - : cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "") - notifyCalls.push(commandString) - const result = { stdout: "", stderr: "", exitCode: 0 } - const promise = Promise.resolve(result) as any - promise.quiet = () => promise - promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p } - return promise - }, - } as any - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") - spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") - - // when - terminal-notifier command is executed - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - - // then - terminal-notifier succeeds directly and fallback is not used - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - const osascriptCall = notifyCalls.find(c => c.includes("osascript")) - expect(tnCall).toBeDefined() - expect(osascriptCall).toBeUndefined() - }) - - test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { - // given - terminal-notifier available but no bundle ID - spyOn(sender, "sendSessionNotification").mockRestore() - const { mockCtx, notifyCalls } = createSenderMockCtx() - spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") - const originalEnv = process.env.__CFBundleIdentifier - delete process.env.__CFBundleIdentifier - - try { - // when - sendSessionNotification is called directly on darwin - await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") - - // then - terminal-notifier used but without -activate flag - expect(notifyCalls.length).toBeGreaterThanOrEqual(1) - const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) - expect(tnCall).toBeDefined() - expect(tnCall).not.toContain("-activate") - } finally { - if (originalEnv !== undefined) { - process.env.__CFBundleIdentifier = originalEnv - } - } - }) - - test("should ignore activity events within grace period", async () => { - jest.useFakeTimers() - jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")) - - try { - // given - a regular session notification is scheduled - const sessionID = "main-grace" - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 50, - skipIfIncompleteTodos: false, - activityGracePeriodMs: 100, - enforceMainSessionFilter: false, - }) - - // when - session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID }, - }, - }) - - // when - activity happens immediately (within grace period) - await hook({ - event: { - type: "tool.execute.before", - properties: { sessionID }, - }, - }) - - // when - idle confirmation delay passes deterministically - jest.advanceTimersByTime(50) - jest.runOnlyPendingTimers() - await Promise.resolve() - - // then - notification SHOULD be sent (activity was within grace period, ignored) - expect(notificationCalls.length).toBeGreaterThanOrEqual(1) - } finally { - jest.clearAllTimers() - jest.useRealTimers() - globalThis.setTimeout = originalSetTimeout - globalThis.clearTimeout = originalClearTimeout - Date.now = originalDateNow - } - }) - - test("should cancel notification for activity after grace period", async () => { - // given - a regular session notification is scheduled - const sessionID = "main-grace-cancel" - - const hook = createSessionNotification(createMockPluginInput(), { - idleConfirmationDelay: 200, - skipIfIncompleteTodos: false, - activityGracePeriodMs: 50, - enforceMainSessionFilter: false, - }) - - // when - session goes idle - await hook({ - event: { - type: "session.idle", - properties: { sessionID }, - }, - }) - - // when - wait for grace period to pass - await new Promise((resolve) => setTimeout(resolve, 60)) - - // when - activity happens after grace period - await hook({ - event: { - type: "tool.execute.before", - properties: { sessionID }, - }, - }) - - // Wait for original delay to pass - await new Promise((resolve) => setTimeout(resolve, 200)) - - // then - notification should NOT be sent (activity cancelled it after grace period) - expect(notificationCalls).toHaveLength(0) - }) -}) diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts deleted file mode 100644 index f9a40f56d4a..00000000000 --- a/src/hooks/session-notification.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { PluginInput } from "@opencode-ai/plugin" -import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state" -import { buildReadyNotificationContent } from "./session-notification-content" -import { type Platform } from "./session-notification-sender" -import * as sessionNotificationSender from "./session-notification-sender" -import { getEventToolName, getQuestionText, getSessionID } from "./session-notification-event-properties" -import { hasIncompleteTodos } from "./session-todo-status" -import { createIdleNotificationScheduler } from "./session-notification-scheduler" -import { createSessionNotificationInit } from "./session-notification-init" - -interface SessionNotificationConfig { - title?: string - message?: string - questionMessage?: string - permissionMessage?: string - playSound?: boolean - soundPath?: string - /** Delay in ms before sending notification to confirm session is still idle (default: 1500) */ - idleConfirmationDelay?: number - /** Skip notification if there are incomplete todos (default: true) */ - skipIfIncompleteTodos?: boolean - /** Maximum number of sessions to track before cleanup (default: 100) */ - maxTrackedSessions?: number - enforceMainSessionFilter?: boolean - /** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */ - activityGracePeriodMs?: number -} - -export function createSessionNotification(ctx: PluginInput, config: SessionNotificationConfig = {}) { - const mergedConfig = { - title: "OpenCode", - message: "Agent is ready for input", - questionMessage: "Agent is asking a question", - permissionMessage: "Agent needs permission to continue", - playSound: false, - soundPath: "", - idleConfirmationDelay: 1500, - skipIfIncompleteTodos: true, - maxTrackedSessions: 100, - enforceMainSessionFilter: true, - ...config, - } - - const sessionNotificationInit = createSessionNotificationInit() - let currentPlatform: Platform | null = null - let defaultSoundPath = mergedConfig.soundPath - - const scheduler = createIdleNotificationScheduler({ - ctx, - platform: "unsupported", - config: mergedConfig, - hasIncompleteTodos, - send: async (hookCtx, platform, sessionID) => { - if (typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function") { - await sessionNotificationSender.sendSessionNotification(hookCtx, platform, mergedConfig.title, mergedConfig.message) - return - } - - const content = await buildReadyNotificationContent(hookCtx, { - sessionID, - baseTitle: mergedConfig.title, - baseMessage: mergedConfig.message, - }) - - await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message) - }, - playSound: sessionNotificationSender.playSessionNotificationSound, - }) - - const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"]) - const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"]) - const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i - - const ensureNotificationPlatform = (): Platform => { - if (currentPlatform) return currentPlatform - - const initialized = sessionNotificationInit.initialize() - currentPlatform = initialized.platform - defaultSoundPath = initialized.defaultSoundPath || mergedConfig.soundPath - return currentPlatform - } - - const shouldNotifyForSession = (sessionID: string): boolean => { - if (subagentSessions.has(sessionID)) return false - - if (mergedConfig.enforceMainSessionFilter) { - const mainSessionID = getMainSessionID() - if (mainSessionID && sessionID !== mainSessionID) return false - } - - return true - } - - return async ({ event }: { event: { type: string; properties?: unknown } }) => { - const props = event.properties as Record | undefined - - if (event.type === "session.created") { - const info = props?.info as Record | undefined - const sessionID = info?.id as string | undefined - if (sessionID) scheduler.markSessionActivity(sessionID) - return - } - - if (event.type === "session.idle") { - const sessionID = getSessionID(props) - if (!sessionID) return - - const platform = ensureNotificationPlatform() - if (platform === "unsupported") return - if (!shouldNotifyForSession(sessionID)) return - - scheduler.scheduleIdleNotification(sessionID) - return - } - - if (event.type === "message.updated") { - const info = props?.info as Record | undefined - const sessionID = getSessionID({ ...props, info }) - if (sessionID) scheduler.markSessionActivity(sessionID) - return - } - - if (PERMISSION_EVENTS.has(event.type)) { - const sessionID = getSessionID(props) - if (!sessionID) return - - const platform = ensureNotificationPlatform() - if (platform === "unsupported") return - if (!shouldNotifyForSession(sessionID)) return - - scheduler.markSessionActivity(sessionID) - await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, mergedConfig.permissionMessage) - if (mergedConfig.playSound && defaultSoundPath) { - await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath) - } - return - } - - if (event.type === "tool.execute.before" || event.type === "tool.execute.after") { - const sessionID = getSessionID(props) - if (sessionID) { - scheduler.markSessionActivity(sessionID) - - if (event.type === "tool.execute.before") { - const toolName = getEventToolName(props)?.toLowerCase() - if (toolName && QUESTION_TOOLS.has(toolName)) { - const platform = ensureNotificationPlatform() - if (platform === "unsupported") return - if (!shouldNotifyForSession(sessionID)) return - - const questionText = getQuestionText(props) - const message = PERMISSION_HINT_PATTERN.test(questionText) ? mergedConfig.permissionMessage : mergedConfig.questionMessage - - await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, message) - if (mergedConfig.playSound && defaultSoundPath) { - await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath) - } - } - } - } - return - } - - if (event.type === "session.deleted") { - const sessionInfo = props?.info as { id?: string } | undefined - if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id) - } - } -} diff --git a/src/hooks/session-todo-status.ts b/src/hooks/session-todo-status.ts index c86752fe5c1..18099e934a9 100644 --- a/src/hooks/session-todo-status.ts +++ b/src/hooks/session-todo-status.ts @@ -1,20 +1,27 @@ 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 { +export type SessionTodoState = "pending" | "clear" | "unknown" + +export async function getSessionTodoState(ctx: PluginInput, sessionID: string): Promise { try { 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") + if (!todos || todos.length === 0) return "clear" + return getIncompleteCount(todos) > 0 ? "pending" : "clear" } catch { - return false + return "unknown" } } + +export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { + return (await getSessionTodoState(ctx, sessionID)) === "pending" +} diff --git a/src/index.telemetry.test.ts b/src/index.telemetry.test.ts index 924a7db2cbe..d7831788edb 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 ba7be1363e1..fba9b479219 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 6778427d0ae..6effd117c7b 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/plugin/event-compaction-agent.test.ts b/src/plugin/event-compaction-agent.test.ts index b5888d7fe58..25c35760f05 100644 --- a/src/plugin/event-compaction-agent.test.ts +++ b/src/plugin/event-compaction-agent.test.ts @@ -26,7 +26,6 @@ function createMinimalEventHandler() { autoUpdateChecker: { event: async () => {} }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, - sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index ea880c145fa..197c595bd4c 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -173,12 +173,11 @@ afterEach(() => { onSessionDeleted: async () => {}, }, } as any, - hooks: { - autoUpdateChecker: { event: async () => {} }, - claudeCodeHooks: { event: async () => {} }, - backgroundNotificationHook: { event: async () => {} }, - sessionNotification: async () => {}, - todoContinuationEnforcer: { handler: async () => {} }, + hooks: { + autoUpdateChecker: { event: async () => {} }, + claudeCodeHooks: { event: async () => {} }, + backgroundNotificationHook: { event: async () => {} }, + todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, @@ -262,16 +261,15 @@ afterEach(() => { onSessionDeleted: async () => {}, }, } as any, - hooks: { - autoUpdateChecker: { - event: async (input: EventInput) => { - dispatchCalls.push(input) + hooks: { + autoUpdateChecker: { + event: async (input: EventInput) => { + dispatchCalls.push(input) + }, }, - }, - claudeCodeHooks: { event: async () => {} }, - backgroundNotificationHook: { event: async () => {} }, - sessionNotification: async () => {}, - todoContinuationEnforcer: { handler: async () => {} }, + claudeCodeHooks: { event: async () => {} }, + backgroundNotificationHook: { event: async () => {} }, + todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, @@ -318,18 +316,17 @@ afterEach(() => { onSessionDeleted: async () => {}, }, } as any, - hooks: { - autoUpdateChecker: { - event: async (input: EventInput) => { - if (input.event.type === "session.idle") { - dispatchCalls.push(input) - } + hooks: { + autoUpdateChecker: { + event: async (input: EventInput) => { + if (input.event.type === "session.idle") { + dispatchCalls.push(input) + } + }, }, - }, - claudeCodeHooks: { event: async () => {} }, - backgroundNotificationHook: { event: async () => {} }, - sessionNotification: async () => {}, - todoContinuationEnforcer: { handler: async () => {} }, + claudeCodeHooks: { event: async () => {} }, + backgroundNotificationHook: { event: async () => {} }, + todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} }, directoryAgentsInjector: { event: async () => {} }, diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 5a5f177b6ef..ee6787dd6b8 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -242,7 +242,6 @@ export function createEventHandler(args: { await runEventHookSafely("legacyPluginToast", hooks.legacyPluginToast?.event, input); await runEventHookSafely("claudeCodeHooks", hooks.claudeCodeHooks?.event, input); await runEventHookSafely("backgroundNotificationHook", hooks.backgroundNotificationHook?.event, input); - await runEventHookSafely("sessionNotification", hooks.sessionNotification, input); await runEventHookSafely("todoContinuationEnforcer", hooks.todoContinuationEnforcer?.handler, input); await runEventHookSafely("unstableAgentBabysitter", hooks.unstableAgentBabysitter?.event, input); await runEventHookSafely("contextWindowMonitor", hooks.contextWindowMonitor?.event, input); diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 9d437bc7501..38e00b93ede 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -6,7 +6,6 @@ import type { PluginContext } from "../types" import { createContextWindowMonitorHook, createSessionRecoveryHook, - createSessionNotification, createThinkModeHook, createModelFallbackHook, createAnthropicContextWindowLimitRecoveryHook, @@ -30,9 +29,6 @@ import { } from "../../hooks" import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" import { - detectExternalNotificationPlugin, - getNotificationConflictWarning, - log, normalizeSDKResponse, } from "../../shared" import { safeCreateHook } from "../../shared/safe-create-hook" @@ -43,7 +39,6 @@ export type SessionHooks = { contextWindowMonitor: ReturnType | null preemptiveCompaction: ReturnType | null sessionRecovery: ReturnType | null - sessionNotification: ReturnType | null thinkMode: ReturnType | null modelFallback: ReturnType | null anthropicContextWindowLimitRecovery: ReturnType | null @@ -95,17 +90,6 @@ export function createSessionHooks(args: { createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental })) : null - let sessionNotification: ReturnType | null = null - if (isHookEnabled("session-notification")) { - const forceEnable = pluginConfig.notification?.force_enable ?? false - const externalNotifier = detectExternalNotificationPlugin(ctx.directory) - if (externalNotifier.detected && !forceEnable) { - log(getNotificationConflictWarning(externalNotifier.pluginName!)) - } else { - sessionNotification = safeHook("session-notification", () => createSessionNotification(ctx)) - } - } - const thinkMode = isHookEnabled("think-mode") ? safeHook("think-mode", () => createThinkModeHook()) : null @@ -277,7 +261,6 @@ export function createSessionHooks(args: { contextWindowMonitor, preemptiveCompaction, sessionRecovery, - sessionNotification, thinkMode, modelFallback, anthropicContextWindowLimitRecovery, diff --git a/src/plugin/tool-execute-before-session-notification.test.ts b/src/plugin/tool-execute-before-session-notification.test.ts deleted file mode 100644 index 970758d8471..00000000000 --- a/src/plugin/tool-execute-before-session-notification.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -const { describe, expect, test, spyOn } = require("bun:test") - -const sessionState = require("../features/claude-code-session-state") -const { createToolExecuteBeforeHandler } = require("./tool-execute-before") - -describe("createToolExecuteBeforeHandler session notification sessionID", () => { - test("uses main session fallback when input sessionID is empty", async () => { - const mainSessionID = "ses_main" - const getMainSessionIDSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(mainSessionID) - - let capturedSessionID: string | undefined - const hooks = { - sessionNotification: async (input) => { - capturedSessionID = input.event.properties?.sessionID - }, - } - - const handler = createToolExecuteBeforeHandler({ - ctx: { client: { session: { messages: async () => ({ data: [] }) } } }, - hooks, - }) - - await handler( - { tool: "question", sessionID: "", callID: "call_q" }, - { args: { questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] } }, - ) - - expect(getMainSessionIDSpy).toHaveBeenCalled() - expect(capturedSessionID).toBe(mainSessionID) - - getMainSessionIDSpy.mockRestore() - }) -}) - -export {} diff --git a/src/plugin/tool-execute-before.test.ts b/src/plugin/tool-execute-before.test.ts index 76d11a33b59..ea56aea3417 100644 --- a/src/plugin/tool-execute-before.test.ts +++ b/src/plugin/tool-execute-before.test.ts @@ -34,60 +34,6 @@ describe("createToolExecuteBeforeHandler", () => { await expect(run).resolves.toBeUndefined() }) - test("triggers session notification hook for question tools", async () => { - let called = false - const ctx = { - client: { - session: { - messages: async () => ({ data: [] }), - }, - }, - } - - const hooks = { - sessionNotification: async (input: { event: { type: string; properties?: Record } }) => { - called = true - expect(input.event.type).toBe("tool.execute.before") - expect(input.event.properties?.sessionID).toBe("ses_q") - expect(input.event.properties?.tool).toBe("question") - }, - } - - const handler = createToolExecuteBeforeHandler({ ctx, hooks }) - const input = { tool: "question", sessionID: "ses_q", callID: "call_q" } - const output = { args: { questions: [{ question: "Proceed?", options: [{ label: "Yes" }] }] } as Record } - - await handler(input, output) - - expect(called).toBe(true) - }) - - test("does not trigger session notification hook for non-question tools", async () => { - let called = false - const ctx = { - client: { - session: { - messages: async () => ({ data: [] }), - }, - }, - } - - const hooks = { - sessionNotification: async () => { - called = true - }, - } - - const handler = createToolExecuteBeforeHandler({ ctx, hooks }) - - await handler( - { tool: "bash", sessionID: "ses_b", callID: "call_b" }, - { args: { command: "pwd" } as Record }, - ) - - expect(called).toBe(false) - }) - describe("task tool subagent_type normalization", () => { const emptyHooks = {} diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 5c54fba7b77..975464745cf 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -77,25 +77,6 @@ export function createToolExecuteBeforeHandler(args: { await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) - const normalizedToolName = input.tool.toLowerCase() - if ( - normalizedToolName === "question" - || normalizedToolName === "ask_user_question" - || normalizedToolName === "askuserquestion" - ) { - const sessionID = input.sessionID || getMainSessionID() - await hooks.sessionNotification?.({ - event: { - type: "tool.execute.before", - properties: { - sessionID, - tool: input.tool, - args: output.args, - }, - }, - }) - } - if (input.tool === "task") { const argsObject = output.args const category = typeof argsObject.category === "string" ? argsObject.category : undefined diff --git a/src/shared/bundled-notify-ownership.test.ts b/src/shared/bundled-notify-ownership.test.ts new file mode 100644 index 00000000000..48af2fa5d02 --- /dev/null +++ b/src/shared/bundled-notify-ownership.test.ts @@ -0,0 +1,411 @@ +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 npm-prefixed external notify with version to bundled owner", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["npm: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 external notify with underscore dist-tag to bundled owner", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify@release_candidate", "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 npm-prefixed recognized notify with underscore dist-tag to bundled owner", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["npm:kdco/notify@release_candidate", "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("does not classify unrelated notify-like plugin names as unsafe", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["team-notify-center", "oh-my-openagent"] }, null, 2) + "\n") + + // when + const result = ensureBundledNotifyOwnership({ projectDirectory: projectDir, packageRoot }) + + // then + expect(result.changedUserConfig).toBe(true) + expect(readConfig(userConfigPath).plugin).toEqual(["team-notify-center", "oh-my-openagent", canonicalEntry]) + }) + + test("fails loudly for custom package-based notify plugin id", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["@custom/opencode-notify", "oh-my-openagent"] }, 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(["@custom/opencode-notify", "oh-my-openagent"]) + }) + + test("fails loudly for custom tuple-based notify package id", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: [["npm:@custom/opencode-notify@1.2.3", {}], "oh-my-openagent"] }, 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([["npm:@custom/opencode-notify@1.2.3", {}], "oh-my-openagent"]) + }) + + test("fails loudly for npm alias string spec targeting custom opencode-notify package", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["alias@npm:@custom/opencode-notify@1.2.3", "oh-my-openagent"] }, 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(["alias@npm:@custom/opencode-notify@1.2.3", "oh-my-openagent"]) + }) + + test("fails loudly for npm alias string spec targeting recognized kdco/notify package", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["alias@npm:kdco/notify@1.2.3", "oh-my-openagent"] }, 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(["alias@npm:kdco/notify@1.2.3", "oh-my-openagent"]) + }) + + test("fails loudly for npm alias tuple spec targeting custom opencode-notify package", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["alias@npm:@custom/opencode-notify@1.2.3", {}], "oh-my-openagent"] }, 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([["alias@npm:@custom/opencode-notify@1.2.3", {}], "oh-my-openagent"]) + }) + + test("fails loudly for npm alias tuple spec targeting recognized kdco/notify package", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["alias@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"] }, 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([["alias@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"]) + }) + + test("fails loudly for non-exact recognized-like spec with nested npm alias in string form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify@npm:kdco/notify@1.2.3", "oh-my-openagent"] }, 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(["kdco/notify@npm:kdco/notify@1.2.3", "oh-my-openagent"]) + }) + + test("fails loudly for non-exact recognized-like spec with nested npm alias in tuple form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["kdco/notify@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"] }, 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([["kdco/notify@npm:kdco/notify@1.2.3", {}], "oh-my-openagent"]) + }) + + test("fails loudly for recognized notify package with file source suffix in string form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["kdco/notify@file:../local", "oh-my-openagent"] }, 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(["kdco/notify@file:../local", "oh-my-openagent"]) + }) + + test("fails loudly for recognized notify package with file source suffix in tuple form", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync( + userConfigPath, + JSON.stringify({ plugin: [["kdco/notify@file:../local", {}], "oh-my-openagent"] }, 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([["kdco/notify@file:../local", {}], "oh-my-openagent"]) + }) + + test("fails loudly for npm-prefixed recognized notify package with workspace source suffix", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + writeFileSync(userConfigPath, JSON.stringify({ plugin: ["npm:kdco/notify@workspace:*", "oh-my-openagent"] }, 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(["npm:kdco/notify@workspace:*", "oh-my-openagent"]) + }) + + test("migrates stale bundled dist/opencode-notify file URL to canonical bundled entry", () => { + // given + const userConfigPath = join(userConfigDir, "opencode.json") + const stalePackageRoot = join(rootDir, "package-old") + mkdirSync(join(stalePackageRoot, "dist", "opencode-notify"), { recursive: true }) + const staleBundledEntry = getBundledNotifyCanonicalEntry(stalePackageRoot) + writeFileSync(userConfigPath, JSON.stringify({ plugin: [staleBundledEntry, "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("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 00000000000..4576856854b --- /dev/null +++ b/src/shared/bundled-notify-ownership.ts @@ -0,0 +1,491 @@ +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) => { + if (normalized === base) return true + if (!normalized.startsWith(`${base}@`)) return false + + const versionSuffix = normalized.slice(base.length + 1) + if (versionSuffix.length === 0) return false + if (versionSuffix.includes("@")) return false + if (versionSuffix.includes("://")) return false + if (versionSuffix.includes(":")) return false + if (versionSuffix.includes("/")) return false + if (versionSuffix.includes("\\")) return false + + return /^[a-z0-9_.*+!~^<>=| -]+$/i.test(versionSuffix) + }) +} + +function isRecognizedNotifyIdWithUnsupportedSuffix(entry: string): boolean { + const normalized = entry.trim().toLowerCase() + + return KNOWN_EXTERNAL_NOTIFY_IDS.some((base) => { + if (!normalized.startsWith(`${base}@`)) return false + return !isRecognizedExternalNotifyId(normalized) + }) +} + +function stripNpmPrefix(entry: string): string { + return entry.startsWith("npm:") ? entry.slice("npm:".length) : entry +} + +function stripVersionSuffix(entry: string): string { + const trimmed = entry.trim() + const versionSeparatorIndex = trimmed.lastIndexOf("@") + if (versionSeparatorIndex <= 0) return trimmed + + const slashIndex = trimmed.indexOf("/") + if (trimmed.startsWith("@") && versionSeparatorIndex <= slashIndex) { + return trimmed + } + + return trimmed.slice(0, versionSeparatorIndex) +} + +function extractPackageIdentifierFromSpec(entry: string): string | null { + const normalized = stripNpmPrefix(entry.trim().toLowerCase()) + if (normalized.length === 0) return null + if (normalized.includes("://")) return null + + const aliasSeparatorIndex = normalized.indexOf("@npm:") + const packageSpec = aliasSeparatorIndex >= 0 + ? normalized.slice(aliasSeparatorIndex + "@npm:".length) + : normalized + + if (packageSpec.length === 0) return null + return stripVersionSuffix(packageSpec) +} + +function isAliasedRecognizedNotifyTarget(entry: string): boolean { + const normalized = stripNpmPrefix(entry.trim().toLowerCase()) + const aliasSeparatorIndex = normalized.indexOf("@npm:") + if (aliasSeparatorIndex <= 0) return false + + const aliasedTargetSpec = normalized.slice(aliasSeparatorIndex + "@npm:".length) + if (aliasedTargetSpec.length === 0) return false + + const aliasedPackageIdentifier = stripVersionSuffix(stripNpmPrefix(aliasedTargetSpec)) + return aliasedPackageIdentifier === "kdco/notify" +} + +function isCustomPackageNotifyCandidate(entry: string): boolean { + const packageIdentifier = extractPackageIdentifierFromSpec(entry) + if (!packageIdentifier) return false + if (packageIdentifier === "opencode-notify") return true + if (/^@[a-z0-9._-]+\/opencode-notify$/i.test(packageIdentifier)) return true + if (/^[a-z0-9._-]+\/opencode-notify$/i.test(packageIdentifier)) return true + return false +} + +function normalizePathForComparison(pathValue: string): string { + return resolve(pathValue).replace(/\\/g, "/").replace(/\/+$/, "") +} + +function tryParseFileUrlPath(entry: string): string | null { + if (!entry.startsWith("file://")) return null + + try { + return fileURLToPath(entry) + } catch { + return null + } +} + +function hasBundledNotifyArtifactPathShape(pathValue: string): boolean { + const normalizedPath = normalizePathForComparison(pathValue) + return normalizedPath.endsWith("/dist/opencode-notify") +} + +function isBundledNotifyArtifactEntry(entry: string, canonicalEntry: string): boolean { + if (entry === canonicalEntry) return true + + const filePath = tryParseFileUrlPath(entry) + if (!filePath) return false + + return hasBundledNotifyArtifactPathShape(filePath) +} + +function isPathBasedNotifyEntry(entry: string): boolean { + if (!isPathLikePluginEntry(entry)) return false + + const filePath = tryParseFileUrlPath(entry) + const pathCandidate = filePath ?? entry + const normalizedPath = normalizePathForComparison(pathCandidate).toLowerCase() + return normalizedPath.includes("/opencode-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 (isBundledNotifyArtifactEntry(entry, canonicalEntry)) { + return { kind: "bundled", entry, index } + } + + if (isRecognizedExternalNotifyId(entry)) { + return { kind: "recognized-external", entry, index } + } + + if (isPathBasedNotifyEntry(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "path-based notify plugin entries are not auto-migrated", + } + } + + if (isRecognizedNotifyIdWithUnsupportedSuffix(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "recognized notify package uses unsupported source/custom suffix", + } + } + + if (isAliasedRecognizedNotifyTarget(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "aliased kdco/notify entries are treated as custom notify owners", + } + } + + if (isCustomPackageNotifyCandidate(entry)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "notify package entry is not an exact recognized kdco/notify identifier", + } + } + + return { kind: "other", entry, index } + } + + const [tupleKey, ...tupleOptions] = entry + if (isBundledNotifyArtifactEntry(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 (isPathBasedNotifyEntry(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "path-based notify plugin entries are not auto-migrated", + } + } + + if (isRecognizedNotifyIdWithUnsupportedSuffix(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "recognized notify package uses unsupported source/custom suffix", + } + } + + if (isAliasedRecognizedNotifyTarget(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "aliased kdco/notify entries are treated as custom notify owners", + } + } + + if (isCustomPackageNotifyCandidate(tupleKey)) { + return { + kind: "unsafe-external", + entry, + index, + reason: "notify package tuple 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/bundled-notify-postinstall-path.test.ts b/src/shared/bundled-notify-postinstall-path.test.ts new file mode 100644 index 00000000000..741f042d8a9 --- /dev/null +++ b/src/shared/bundled-notify-postinstall-path.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +describe("bundled notify postinstall bootstrap path", () => { + test("build script emits ownership module consumed by postinstall", () => { + // given + const packageJsonPath = resolve(import.meta.dir, "..", "..", "package.json") + const postinstallPath = resolve(import.meta.dir, "..", "..", "postinstall.mjs") + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + scripts?: { build?: string } + } + const postinstallScript = readFileSync(postinstallPath, "utf-8") + + // when + const buildScript = packageJson.scripts?.build ?? "" + + // then + expect(buildScript).toContain("src/shared/bundled-notify-ownership.ts --outdir dist/shared") + expect(postinstallScript).toContain("dist/shared/bundled-notify-ownership.js") + }) +}) diff --git a/src/shared/external-plugin-detector.test.ts b/src/shared/external-plugin-detector.test.ts index 64c27e2d327..4eb1dcbbc35 100644 --- a/src/shared/external-plugin-detector.test.ts +++ b/src/shared/external-plugin-detector.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" -import { detectExternalNotificationPlugin, getNotificationConflictWarning, detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./external-plugin-detector" +import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./external-plugin-detector" import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" @@ -23,356 +23,21 @@ describe("external-plugin-detector", () => { fs.rmSync(tempHomeDir, { recursive: true, force: true }) }) - describe("detectExternalNotificationPlugin", () => { - test("should return detected=false when no plugins configured", () => { - // given - empty directory - // when - const result = detectExternalNotificationPlugin(tempDir) - // then - expect(result.detected).toBe(false) - expect(result.pluginName).toBeNull() - }) - - test("should return detected=false when only oh-my-opencode is configured", () => { - // given - opencode.json with only oh-my-opencode - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(false) - expect(result.pluginName).toBeNull() - expect(result.allPlugins).toContain("oh-my-opencode") - }) - - test("should detect opencode-notifier plugin", () => { - // given - opencode.json with opencode-notifier - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - - test("should detect opencode-notifier with version suffix", () => { - // given - opencode.json with versioned opencode-notifier - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode", "opencode-notifier@1.2.3"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - - test("should detect @mohak34/opencode-notifier", () => { - // given - opencode.json with scoped package name - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode", "@mohak34/opencode-notifier"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - returns the matched known plugin pattern, not the full entry - expect(result.detected).toBe(true) - expect(result.pluginName).toContain("opencode-notifier") - }) - - test("should safely handle tuple-format plugin entries without crashing (fixes #3122)", () => { - // given - opencode.json with array/tuple plugin entries - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - plugin: [ - "oh-my-opencode", - ["advanced-tuple-plugin", { debug: true }], - "opencode-notifier" - ] - }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - should detect opencode-notifier without crashing on the tuple entry - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - expect(result.allPlugins).toContain("oh-my-opencode") - expect(result.allPlugins).toContain("advanced-tuple-plugin") - expect(result.allPlugins).not.toContain(["advanced-tuple-plugin", { debug: true }]) - }) - - test("should handle JSONC format with comments", () => { - // given - opencode.jsonc with comments - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.jsonc"), - `{ - // This is a comment - "plugin": [ - "oh-my-opencode", - "opencode-notifier" // Another comment - ] - }` - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - }) - - describe("false positive prevention", () => { - test("should NOT match my-opencode-notifier-fork (suffix variation)", () => { - // given - plugin with similar name but different suffix - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["my-opencode-notifier-fork"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(false) - expect(result.pluginName).toBeNull() - }) - - test("should NOT match some-other-plugin/opencode-notifier-like (path with similar name)", () => { - // given - plugin path containing similar substring - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["some-other-plugin/opencode-notifier-like"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(false) - expect(result.pluginName).toBeNull() - }) - - test("should NOT match opencode-notifier-extended (prefix match but different package)", () => { - // given - plugin with prefix match but extended name - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["opencode-notifier-extended"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(false) - expect(result.pluginName).toBeNull() - }) - - test("should match opencode-notifier exactly", () => { - // given - exact match - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["opencode-notifier"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - - test("should match opencode-notifier@1.2.3 (version suffix)", () => { - // given - version suffix - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["opencode-notifier@1.2.3"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - - test("should match @mohak34/opencode-notifier (scoped package)", () => { - // given - scoped package - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["@mohak34/opencode-notifier"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toContain("opencode-notifier") - }) - - test("should match npm:opencode-notifier (npm prefix)", () => { - // given - npm prefix - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["npm:opencode-notifier"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - - test("should match npm:opencode-notifier@2.0.0 (npm prefix with version)", () => { - // given - npm prefix with version - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["npm:opencode-notifier@2.0.0"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - - test("should match file:///path/to/opencode-notifier (file path)", () => { - // given - file path - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-notifier"] }) - ) - - // when - const result = detectExternalNotificationPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-notifier") - }) - }) - - describe("getNotificationConflictWarning", () => { - test("should generate warning message with plugin name", () => { - // when - const warning = getNotificationConflictWarning("opencode-notifier") - - // then - expect(warning).toContain("opencode-notifier") - expect(warning).toContain("session.idle") - expect(warning).toContain("auto-disabled") - expect(warning).toContain("force_enable") - }) - }) - describe("detectExternalSkillPlugin", () => { - test("should return detected=false when no plugins configured", () => { - // given - empty directory - // when - const result = detectExternalSkillPlugin(tempDir) - // then - expect(result.detected).toBe(false) - expect(result.pluginName).toBeNull() - }) - - test("should return detected=false when only oh-my-opencode is configured", () => { - // given - opencode.json with only oh-my-opencode - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode"] }) - ) - + test("returns detected=false when no plugins configured", () => { // when const result = detectExternalSkillPlugin(tempDir) // then expect(result.detected).toBe(false) expect(result.pluginName).toBeNull() - expect(result.allPlugins).toContain("oh-my-opencode") }) - test("should detect opencode-skills plugin", () => { - // given - opencode.json with opencode-skills - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode", "opencode-skills"] }) - ) - - // when - const result = detectExternalSkillPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-skills") - }) - - test("should detect opencode-skills with version suffix", () => { - // given - opencode.json with versioned opencode-skills + test("detects opencode-skills plugin", () => { + // given const opencodeDir = path.join(tempDir, ".opencode") fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode", "opencode-skills@1.2.3"] }) - ) + fs.writeFileSync(path.join(opencodeDir, "opencode.json"), JSON.stringify({ plugin: ["opencode-skills"] })) // when const result = detectExternalSkillPlugin(tempDir) @@ -382,14 +47,11 @@ describe("external-plugin-detector", () => { expect(result.pluginName).toBe("opencode-skills") }) - test("should detect @opencode/skills scoped package", () => { - // given - opencode.json with scoped package name + test("detects @opencode/skills scoped package", () => { + // given const opencodeDir = path.join(tempDir, ".opencode") fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["oh-my-opencode", "@opencode/skills"] }) - ) + fs.writeFileSync(path.join(opencodeDir, "opencode.json"), JSON.stringify({ plugin: ["@opencode/skills"] })) // when const result = detectExternalSkillPlugin(tempDir) @@ -399,41 +61,7 @@ describe("external-plugin-detector", () => { expect(result.pluginName).toBe("@opencode/skills") }) - test("should detect npm:opencode-skills", () => { - // given - npm prefix - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["npm:opencode-skills"] }) - ) - - // when - const result = detectExternalSkillPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-skills") - }) - - test("should detect file:///path/to/opencode-skills", () => { - // given - file path - const opencodeDir = path.join(tempDir, ".opencode") - fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["file:///home/user/plugins/opencode-skills"] }) - ) - - // when - const result = detectExternalSkillPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-skills") - }) - - test("should detect user-level opencode-skills when project config exists without plugins", async () => { + test("detects user-level plugin when project config has no plugin list", async () => { // given const projectConfigDir = path.join(tempDir, ".opencode") const userConfigDir = path.join(tempHomeDir, ".config", "opencode") @@ -458,14 +86,11 @@ describe("external-plugin-detector", () => { expect(result.allPlugins).toEqual(["opencode-skills"]) }) - test("should NOT match opencode-skills-extra (suffix variation)", () => { - // given - plugin with similar name but different suffix + test("does not match opencode-skills-extra", () => { + // given const opencodeDir = path.join(tempDir, ".opencode") fs.mkdirSync(opencodeDir, { recursive: true }) - fs.writeFileSync( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ plugin: ["opencode-skills-extra"] }) - ) + fs.writeFileSync(path.join(opencodeDir, "opencode.json"), JSON.stringify({ plugin: ["opencode-skills-extra"] })) // when const result = detectExternalSkillPlugin(tempDir) @@ -477,7 +102,7 @@ describe("external-plugin-detector", () => { }) describe("getSkillPluginConflictWarning", () => { - test("should generate warning message with plugin name", () => { + test("generates warning message with plugin name", () => { // when const warning = getSkillPluginConflictWarning("opencode-skills") diff --git a/src/shared/external-plugin-detector.ts b/src/shared/external-plugin-detector.ts index 818d1c89381..c2a4ace34a0 100644 --- a/src/shared/external-plugin-detector.ts +++ b/src/shared/external-plugin-detector.ts @@ -1,23 +1,12 @@ /** * Detects external plugins that may conflict with oh-my-opencode features. - * Used to prevent crashes from concurrent notification plugins. + * Used to prevent duplicate feature ownership. */ import { loadOpencodePlugins } from "./load-opencode-plugins" import { log } from "./logger" import { CONFIG_BASENAME, PLUGIN_NAME } from "./plugin-identity" -/** - * Known notification plugins that conflict with oh-my-opencode's session-notification. - * Both plugins listen to session.idle and send notifications simultaneously, - * which can cause crashes on Windows due to resource contention. - */ -const KNOWN_NOTIFICATION_PLUGINS = [ - "opencode-notifier", - "@mohak34/opencode-notifier", - "mohak34/opencode-notifier", -] - /** * Known skill plugins that conflict with oh-my-opencode's skill loading. * Both plugins scan ~/.config/opencode/skills/ and register tools independently, @@ -43,44 +32,12 @@ function matchesKnownPlugin(entry: string, knownPlugins: readonly string[]): str return null } -export interface ExternalNotifierResult { - detected: boolean - pluginName: string | null - allPlugins: string[] -} - export interface ExternalSkillPluginResult { detected: boolean pluginName: string | null allPlugins: string[] } -/** - * Detect if any external notification plugin is configured. - * Returns information about detected plugins for logging/warning. - */ -export function detectExternalNotificationPlugin(directory: string): ExternalNotifierResult { - const plugins = loadOpencodePlugins(directory) - - for (const plugin of plugins) { - const match = matchesKnownPlugin(plugin, KNOWN_NOTIFICATION_PLUGINS) - if (match) { - log(`Detected external notification plugin: ${plugin}`) - return { - detected: true, - pluginName: match, - allPlugins: plugins, - } - } - } - - return { - detected: false, - pluginName: null, - allPlugins: plugins, - } -} - /** * Detect if any external skill plugin is configured. * Returns information about detected plugins for logging/warning. @@ -107,22 +64,6 @@ export function detectExternalSkillPlugin(directory: string): ExternalSkillPlugi } } -/** - * Generate a warning message for users with conflicting notification plugins. - */ -export function getNotificationConflictWarning(pluginName: string): string { - return `[${PLUGIN_NAME}] External notification plugin detected: ${pluginName} - -Both ${PLUGIN_NAME} and ${pluginName} listen to session.idle events. - Running both simultaneously can cause crashes on Windows. - - ${PLUGIN_NAME}'s session-notification has been auto-disabled. - - To use ${PLUGIN_NAME}'s notifications instead, either: - 1. Remove ${pluginName} from your opencode.json plugins - 2. Or set "notification": { "force_enable": true } in ${CONFIG_BASENAME}.json` -} - /** * Generate a warning message for users with conflicting skill plugins. */ diff --git a/src/shared/index.ts b/src/shared/index.ts index e99234c3323..6e3c2e1f6b8 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.test.ts b/src/shared/migration/config-migration.test.ts index ff59d7ca346..5f7a89a865f 100644 --- a/src/shared/migration/config-migration.test.ts +++ b/src/shared/migration/config-migration.test.ts @@ -168,3 +168,48 @@ describe("migrateConfigFile backup skipping", () => { expect(backupFiles.length).toBe(1) }) }) + +describe("migrateConfigFile session notification cleanup", () => { + test("removes legacy notification.force_enable config", () => { + // given + const workdir = createWorkdir() + const configPath = join(workdir, "oh-my-opencode.json") + const rawConfig: Record = { + notification: { force_enable: true }, + disabled_hooks: ["comment-checker"], + } + writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n") + + // when + const needsWrite = migrateConfigFile(configPath, rawConfig) + + // then + expect(needsWrite).toBe(true) + expect(rawConfig.notification).toBeUndefined() + expect(rawConfig.disabled_hooks).toEqual(["comment-checker"]) + + const persistedConfig = JSON.parse(readFileSync(configPath, "utf-8")) as Record + expect(persistedConfig.notification).toBeUndefined() + expect(persistedConfig.disabled_hooks).toEqual(["comment-checker"]) + }) + + test("filters removed session-notification hook from disabled_hooks", () => { + // given + const workdir = createWorkdir() + const configPath = join(workdir, "oh-my-opencode.json") + const rawConfig: Record = { + disabled_hooks: ["session-notification", "comment-checker"], + } + writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n") + + // when + const needsWrite = migrateConfigFile(configPath, rawConfig) + + // then + expect(needsWrite).toBe(true) + expect(rawConfig.disabled_hooks).toEqual(["comment-checker"]) + + const persistedConfig = JSON.parse(readFileSync(configPath, "utf-8")) as Record + expect(persistedConfig.disabled_hooks).toEqual(["comment-checker"]) + }) +}) diff --git a/src/shared/migration/config-migration.ts b/src/shared/migration/config-migration.ts index 792ca108322..6895c920a82 100644 --- a/src/shared/migration/config-migration.ts +++ b/src/shared/migration/config-migration.ts @@ -113,6 +113,12 @@ export function migrateConfigFile( } } + if ("notification" in copy) { + delete copy.notification + needsWrite = true + log("Removed obsolete notification config; session alerts now use the bundled KDCO notify integration") + } + if (copy.disabled_agents && Array.isArray(copy.disabled_agents)) { const migrated: string[] = [] let changed = false diff --git a/src/shared/migration/hook-names.ts b/src/shared/migration/hook-names.ts index 09dde113a2b..e3bbed2d513 100644 --- a/src/shared/migration/hook-names.ts +++ b/src/shared/migration/hook-names.ts @@ -11,6 +11,7 @@ export const HOOK_NAME_MAP: Record = { "empty-message-sanitizer": null, "delegate-task-english-directive": null, "gpt-permission-continuation": null, + "session-notification": null, } export function migrateHookNames( diff --git a/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts b/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts index cfaf9f49788..eb8a8e0d3cf 100644 --- a/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts +++ b/src/tools/call-omo-agent/reused-sync-session-delete-cleanup.test.ts @@ -28,7 +28,6 @@ function createMinimalEventHandler() { autoUpdateChecker: { event: async () => {} }, claudeCodeHooks: { event: async () => {} }, backgroundNotificationHook: { event: async () => {} }, - sessionNotification: async () => {}, todoContinuationEnforcer: { handler: async () => {} }, unstableAgentBabysitter: { event: async () => {} }, contextWindowMonitor: { event: async () => {} },