refactor(hooks): remove built-in session-notification subsystem

This commit is contained in:
Kenny
2026-04-19 12:08:14 +08:00
parent 017e4ef1c7
commit c21ad22134
37 changed files with 137 additions and 2655 deletions
+4 -4
View File
@@ -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
```
-9
View File
@@ -5720,15 +5720,6 @@
},
"additionalProperties": false
},
"notification": {
"type": "object",
"properties": {
"force_enable": {
"type": "boolean"
}
},
"additionalProperties": false
},
"model_capabilities": {
"type": "object",
"properties": {
+17
View File
@@ -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:
+10 -8
View File
@@ -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
+2 -1
View File
@@ -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 |
+11 -9
View File
@@ -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
-1
View File
@@ -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"
-1
View File
@@ -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",
-8
View File
@@ -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<typeof NotificationConfigSchema>
@@ -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(),
+4 -6
View File
@@ -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 |
-4
View File
@@ -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";
@@ -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 {}
-145
View File
@@ -1,145 +0,0 @@
import { normalizeSDKResponse } from "../shared"
type ReadyNotificationContext = {
client: {
session: {
get?: (input: { path: { id: string } }) => Promise<unknown>
messages?: (input: { path: { id: string }; query: { directory: string } }) => Promise<unknown>
}
}
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<string> {
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<SessionMessage[]> {
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,
}
}
@@ -1,51 +0,0 @@
type EventProperties = Record<string, unknown> | undefined
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getEventInfo(properties: EventProperties): Record<string, unknown> | 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 : ""
}
@@ -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, "; ")
}
-31
View File
@@ -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,
}
}
@@ -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 {}
-188
View File
@@ -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<boolean>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void>
playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void>
}) {
const notifiedSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
const sessionActivitySinceIdle = new Set<string>()
const notificationVersions = new Map<string, number>()
const executingNotifications = new Set<string>()
const scheduledAt = new Map<string, number>()
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<void> {
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,
}
}
@@ -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<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
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<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
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<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
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<typeof result> & {
quiet: () => Promise<typeof result>
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<typeof result> & {
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<typeof result> & {
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<typeof result> & {
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<typeof result> & {
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<typeof result> & {
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<typeof result> & {
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<typeof result> & {
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")
})
})
})
})
-117
View File
@@ -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<void> {
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<void> {
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
}
}
}
-80
View File
@@ -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<string | null> {
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<string | null> {
let cachedPath: string | null = null
let pending: Promise<string | null> | 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)
})
}
}
-637
View File
@@ -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<typeof sender.sendSessionNotification>[0],
_platform: Parameters<typeof sender.sendSessionNotification>[1],
_title: Parameters<typeof sender.sendSessionNotification>[2],
message: Parameters<typeof sender.sendSessionNotification>[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)
})
})
-169
View File
@@ -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<string, unknown> | undefined
if (event.type === "session.created") {
const info = props?.info as Record<string, unknown> | 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<string, unknown> | 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)
}
}
}
@@ -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 () => {} },
+23 -26
View File
@@ -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 () => {} },
-1
View File
@@ -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);
-17
View File
@@ -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<typeof createContextWindowMonitorHook> | null
preemptiveCompaction: ReturnType<typeof createPreemptiveCompactionHook> | null
sessionRecovery: ReturnType<typeof createSessionRecoveryHook> | null
sessionNotification: ReturnType<typeof createSessionNotification> | null
thinkMode: ReturnType<typeof createThinkModeHook> | null
modelFallback: ReturnType<typeof createModelFallbackHook> | null
anthropicContextWindowLimitRecovery: ReturnType<typeof createAnthropicContextWindowLimitRecoveryHook> | null
@@ -95,17 +90,6 @@ export function createSessionHooks(args: {
createSessionRecoveryHook(ctx, { experimental: pluginConfig.experimental }))
: null
let sessionNotification: ReturnType<typeof createSessionNotification> | 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,
@@ -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 {}
-54
View File
@@ -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<string, unknown> } }) => {
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<string, unknown> }
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<string, unknown> },
)
expect(called).toBe(false)
})
describe("task tool subagent_type normalization", () => {
const emptyHooks = {}
-19
View File
@@ -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
+13 -388
View File
@@ -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")
+1 -60
View File
@@ -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.
*/
@@ -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<string, unknown> = {
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<string, unknown>
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<string, unknown> = {
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<string, unknown>
expect(persistedConfig.disabled_hooks).toEqual(["comment-checker"])
})
})
+6
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ export const HOOK_NAME_MAP: Record<string, string | null> = {
"empty-message-sanitizer": null,
"delegate-task-english-directive": null,
"gpt-permission-continuation": null,
"session-notification": null,
}
export function migrateHookNames(
@@ -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 () => {} },