From c21ad221342f81aba0fa75fe9f757c0614027237 Mon Sep 17 00:00:00 2001 From: Kenny Date: Sun, 19 Apr 2026 12:08:14 +0800 Subject: [PATCH] refactor(hooks): remove built-in session-notification subsystem --- AGENTS.md | 8 +- assets/oh-my-opencode.schema.json | 9 - docs/guide/installation.md | 17 + docs/reference/configuration.md | 18 +- docs/reference/features.md | 3 +- src/config/AGENTS.md | 20 +- src/config/schema.ts | 1 - src/config/schema/hooks.ts | 1 - src/config/schema/notification.ts | 8 - src/config/schema/oh-my-opencode-config.ts | 2 - src/hooks/AGENTS.md | 10 +- src/hooks/index.ts | 4 - .../session-notification-content.test.ts | 67 -- src/hooks/session-notification-content.ts | 145 ---- .../session-notification-event-properties.ts | 51 -- src/hooks/session-notification-formatting.ts | 25 - src/hooks/session-notification-init.ts | 31 - .../session-notification-input-needed.test.ts | 145 ---- src/hooks/session-notification-scheduler.ts | 188 ------ src/hooks/session-notification-sender.test.ts | 345 ---------- src/hooks/session-notification-sender.ts | 117 ---- src/hooks/session-notification-utils.ts | 80 --- src/hooks/session-notification.test.ts | 637 ------------------ src/hooks/session-notification.ts | 169 ----- src/plugin/event-compaction-agent.test.ts | 1 - src/plugin/event.test.ts | 49 +- src/plugin/event.ts | 1 - src/plugin/hooks/create-session-hooks.ts | 17 - ...xecute-before-session-notification.test.ts | 35 - src/plugin/tool-execute-before.test.ts | 54 -- src/plugin/tool-execute-before.ts | 19 - src/shared/external-plugin-detector.test.ts | 401 +---------- src/shared/external-plugin-detector.ts | 61 +- src/shared/migration/config-migration.test.ts | 45 ++ src/shared/migration/config-migration.ts | 6 + src/shared/migration/hook-names.ts | 1 + ...reused-sync-session-delete-cleanup.test.ts | 1 - 37 files changed, 137 insertions(+), 2655 deletions(-) delete mode 100644 src/config/schema/notification.ts delete mode 100644 src/hooks/session-notification-content.test.ts delete mode 100644 src/hooks/session-notification-content.ts delete mode 100644 src/hooks/session-notification-event-properties.ts delete mode 100644 src/hooks/session-notification-formatting.ts delete mode 100644 src/hooks/session-notification-init.ts delete mode 100644 src/hooks/session-notification-input-needed.test.ts delete mode 100644 src/hooks/session-notification-scheduler.ts delete mode 100644 src/hooks/session-notification-sender.test.ts delete mode 100644 src/hooks/session-notification-sender.ts delete mode 100644 src/hooks/session-notification-utils.ts delete mode 100644 src/hooks/session-notification.test.ts delete mode 100644 src/hooks/session-notification.ts delete mode 100644 src/plugin/tool-execute-before-session-notification.test.ts diff --git a/AGENTS.md b/AGENTS.md index 02af070b0..6e16a91e1 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 ``` diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 056c84342..c77a64b48 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 582b5d8ba..ecede58d9 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -29,6 +29,23 @@ 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` + +For session alerts and cmux-related terminal UX, install KDCO `opencode-notify` (`kdco/notify`) in your OpenCode plugin list. + +```json +{ + "plugin": ["oh-my-openagent", "kdco/notify"] +} +``` + +`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 04f510b6d..e5d6be4df 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,17 @@ 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 -`force_enable` (`false`) - force session-notification even if external notification plugins are detected. +For session alerts and cmux-related terminal UX, install KDCO `opencode-notify` (`kdco/notify`) in your OpenCode plugin list. + +`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 366554b6c..c7b865d31 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 now provided by KDCO `opencode-notify` (`kdco/notify`). oh-my-opencode no longer owns built-in `session-notification` transport. + #### Task Management | Hook | Event | Description | diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index d180669b3..bdd3b7d0f 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 04dd0b15b..710e65d39 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 fea9c6371..44040c070 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 48b73da35..000000000 --- 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 e62413d26..b2a701786 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 135338424..94ee1909a 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 8fd15af2f..ed9a22e16 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 39ae0660f..000000000 --- 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 eaf33180d..000000000 --- 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 b51edf81b..000000000 --- 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 c39cb30d8..000000000 --- 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 3dab42ea6..000000000 --- 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 f85d9154d..000000000 --- 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 afea12c7f..000000000 --- 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 2747109cf..000000000 --- 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 504385ffa..000000000 --- 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 cf4ca06ea..000000000 --- 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 11a04b03b..000000000 --- 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 f9a40f56d..000000000 --- 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/plugin/event-compaction-agent.test.ts b/src/plugin/event-compaction-agent.test.ts index b5888d7fe..25c35760f 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 ea880c145..197c595bd 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 5a5f177b6..ee6787dd6 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 9d437bc75..38e00b93e 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 970758d84..000000000 --- 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 76d11a33b..ea56aea34 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 5c54fba7b..975464745 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/external-plugin-detector.test.ts b/src/shared/external-plugin-detector.test.ts index 64c27e2d3..4eb1dcbbc 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,339 +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 + 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"] }) - ) + fs.writeFileSync(path.join(opencodeDir, "opencode.json"), JSON.stringify({ plugin: ["opencode-skills"] })) // when const result = detectExternalSkillPlugin(tempDir) @@ -365,31 +47,11 @@ describe("external-plugin-detector", () => { 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 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@1.2.3"] }) - ) - - // when - const result = detectExternalSkillPlugin(tempDir) - - // then - expect(result.detected).toBe(true) - expect(result.pluginName).toBe("opencode-skills") - }) - - test("should detect @opencode/skills scoped package", () => { - // 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", "@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 818d1c893..c2a4ace34 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/migration/config-migration.test.ts b/src/shared/migration/config-migration.test.ts index ff59d7ca3..5f7a89a86 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 792ca1083..b608e8c64 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; use KDCO opencode-notify (kdco/notify) for session alerts") + } + 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 09dde113a..e3bbed2d5 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 cfaf9f497..eb8a8e0d3 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 () => {} },