refactor(hooks): remove built-in session-notification subsystem
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -5720,15 +5720,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"notification": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"force_enable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"model_capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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, "; ")
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 () => {} },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {} },
|
||||
|
||||
Reference in New Issue
Block a user