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

This commit is contained in:
Kenny
2026-04-19 12:08:14 +08:00
parent 017e4ef1c7
commit c21ad22134
37 changed files with 137 additions and 2655 deletions
+4 -6
View File
@@ -1,14 +1,14 @@
# src/hooks/ — 52 Lifecycle Hooks
# src/hooks/ — 51 Lifecycle Hooks
**Generated:** 2026-04-18
## OVERVIEW
52 hooks across dedicated modules and standalone files. Three-tier composition: Core(43) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
51 hooks across dedicated modules and standalone files. Three-tier composition: Core(42) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern.
## HOOK TIERS
### Tier 1: Session Hooks (24) — `create-session-hooks.ts`
### Tier 1: Session Hooks (23) — `create-session-hooks.ts`
## STRUCTURE
```
hooks/
@@ -18,7 +18,7 @@ hooks/
├── anthropic-effort/ # Reasoning effort level adjustment
├── auto-slash-command/ # Detects /command patterns
├── auto-update-checker/ # Plugin update check
├── background-notification/ # OS notification
├── background-notification/ # Background task reminder injection
├── category-skill-reminder/ # Reminds of category skills
├── claude-code-hooks/ # settings.json compat layer
├── comment-checker/ # Prevents AI slop
@@ -67,7 +67,6 @@ hooks/
| contextWindowMonitor | session.idle | Track context window usage |
| preemptiveCompaction | session.idle | Trigger compaction before limit |
| sessionRecovery | session.error | Auto-retry on recoverable errors |
| sessionNotification | session.idle | OS notifications on completion |
| thinkMode | chat.params | Model variant switching (extended thinking) |
| anthropicContextWindowLimitRecovery | session.error | Multi-strategy context recovery (truncation, compaction) |
| autoUpdateChecker | session.created | Check npm for plugin updates |
@@ -164,7 +163,6 @@ Conditional rules injection from AGENTS.md, config, skill rules. Evaluates condi
| context-window-monitor.ts | Track context window percentage |
| preemptive-compaction.ts | Trigger compaction before hard limit |
| tool-output-truncator.ts | Truncate tool output by token count |
| session-notification.ts + 4 helpers | OS notification on session completion |
| empty-task-response-detector.ts | Detect empty/failed task responses |
| session-todo-status.ts | Todo completion status tracking |
-4
View File
@@ -1,10 +1,6 @@
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
export { createContextWindowMonitorHook } from "./context-window-monitor";
export { createSessionNotification } from "./session-notification";
export { sendSessionNotification, playSessionNotificationSound, detectPlatform, getDefaultSoundPath } from "./session-notification-sender";
export { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting";
export { hasIncompleteTodos } from "./session-todo-status";
export { createIdleNotificationScheduler } from "./session-notification-scheduler";
export { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker";
export { createToolOutputTruncatorHook } from "./tool-output-truncator";
@@ -1,67 +0,0 @@
const { describe, expect, test } = require("bun:test")
import { buildReadyNotificationContent } from "./session-notification-content"
describe("buildReadyNotificationContent", () => {
describe("#given session metadata and messages exist", () => {
test("#when ready notification content is built, #then it includes session title, last user query, and last assistant line", async () => {
const ctx = {
directory: "/tmp/test",
client: {
session: {
get: async () => ({ data: { title: "Bugfix session" } }),
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "Investigate\nthis flaky test" }],
},
{
info: { role: "assistant" },
parts: [{ type: "text", text: "First line\nFinal answer line" }],
},
],
}),
},
},
}
const result = await buildReadyNotificationContent(ctx, {
sessionID: "ses_123",
baseTitle: "OpenCode",
baseMessage: "Agent is ready for input",
})
expect(result).toEqual({
title: "OpenCode · Bugfix session",
message: "Agent is ready for input\nUser: Investigate this flaky test\nAssistant: Final answer line",
})
})
})
describe("#given session APIs do not provide rich data", () => {
test("#when ready notification content is built, #then it falls back to session id and the base message", async () => {
const ctx = {
directory: "/tmp/test",
client: {
session: {
get: async () => ({ data: {} }),
messages: async () => ({ data: [] }),
},
},
}
const result = await buildReadyNotificationContent(ctx, {
sessionID: "ses_fallback",
baseTitle: "OpenCode",
baseMessage: "Agent is ready for input",
})
expect(result).toEqual({
title: "OpenCode · ses_fallback",
message: "Agent is ready for input",
})
})
})
})
export {}
-145
View File
@@ -1,145 +0,0 @@
import { normalizeSDKResponse } from "../shared"
type ReadyNotificationContext = {
client: {
session: {
get?: (input: { path: { id: string } }) => Promise<unknown>
messages?: (input: { path: { id: string }; query: { directory: string } }) => Promise<unknown>
}
}
directory: string
}
type SessionInfo = {
title?: string
}
type SessionMessagePart = {
type?: string
text?: string
}
type SessionMessage = {
info?: {
role?: string
error?: unknown
}
parts?: SessionMessagePart[]
}
type ReadyNotificationInput = {
sessionID: string
baseTitle: string
baseMessage: string
}
function extractMessageText(message: SessionMessage | undefined): string {
return (message?.parts ?? [])
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text?.trim() ?? "")
.filter(Boolean)
.join("\n")
}
function collapseWhitespace(text: string): string {
return text
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join(" ")
}
function getLastNonEmptyLine(text: string): string {
const lines = text
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
return lines.at(-1) ?? ""
}
function findLastMessage(messages: SessionMessage[], role: "user" | "assistant"): SessionMessage | undefined {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index]
if (message.info?.role !== role) continue
if (role === "assistant" && message.info?.error) continue
if (!extractMessageText(message)) continue
return message
}
return undefined
}
async function readSessionTitle(
ctx: ReadyNotificationContext,
sessionID: string,
): Promise<string> {
if (typeof ctx.client.session.get !== "function") {
return sessionID
}
try {
const response = await ctx.client.session.get({ path: { id: sessionID } })
const sessionInfo = normalizeSDKResponse(response, null as SessionInfo | null, {
preferResponseOnMissingData: true,
})
if (sessionInfo?.title && sessionInfo.title.trim().length > 0) {
return sessionInfo.title.trim()
}
} catch {
}
return sessionID
}
async function readSessionMessages(
ctx: ReadyNotificationContext,
sessionID: string,
): Promise<SessionMessage[]> {
if (typeof ctx.client.session.messages !== "function") {
return []
}
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = normalizeSDKResponse(response, [] as SessionMessage[], {
preferResponseOnMissingData: true,
})
return Array.isArray(messages) ? messages : []
} catch {
return []
}
}
export async function buildReadyNotificationContent(
ctx: ReadyNotificationContext,
input: ReadyNotificationInput,
): Promise<{ title: string; message: string }> {
const [sessionTitle, messages] = await Promise.all([
readSessionTitle(ctx, input.sessionID),
readSessionMessages(ctx, input.sessionID),
])
const lastUserText = collapseWhitespace(extractMessageText(findLastMessage(messages, "user")))
const lastAssistantLine = getLastNonEmptyLine(
extractMessageText(findLastMessage(messages, "assistant")),
)
const detailLines = [
lastUserText ? `User: ${lastUserText}` : "",
lastAssistantLine ? `Assistant: ${lastAssistantLine}` : "",
].filter(Boolean)
return {
title: `${input.baseTitle} · ${sessionTitle}`,
message: detailLines.length > 0
? [input.baseMessage, ...detailLines].join("\n")
: input.baseMessage,
}
}
@@ -1,51 +0,0 @@
type EventProperties = Record<string, unknown> | undefined
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getEventInfo(properties: EventProperties): Record<string, unknown> | undefined {
const info = properties?.info
return isRecord(info) ? info : undefined
}
export function getSessionID(properties: EventProperties): string | undefined {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = getEventInfo(properties)
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
export function getEventToolName(properties: EventProperties): string | undefined {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
export function getQuestionText(properties: EventProperties): string {
const args = properties?.args
if (!isRecord(args)) return ""
const questions = args.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0]
if (!isRecord(firstQuestion)) return ""
const questionText = firstQuestion.question
return typeof questionText === "string" ? questionText : ""
}
@@ -1,25 +0,0 @@
export function escapeAppleScriptText(input: string): string {
return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
}
export function escapePowerShellSingleQuotedText(input: string): string {
return input.replace(/'/g, "''")
}
export function buildWindowsToastScript(title: string, message: string): string {
const psTitle = escapePowerShellSingleQuotedText(title)
const psMessage = escapePowerShellSingleQuotedText(message)
return `
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
$Template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$RawXml = [xml] $Template.GetXml()
($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '1'}).AppendChild($RawXml.CreateTextNode('${psTitle}')) | Out-Null
($RawXml.toast.visual.binding.text | Where-Object {$_.id -eq '2'}).AppendChild($RawXml.CreateTextNode('${psMessage}')) | Out-Null
$SerializedXml = New-Object Windows.Data.Xml.Dom.XmlDocument
$SerializedXml.LoadXml($RawXml.OuterXml)
$Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
$Notifier.Show($Toast)
`.trim().replace(/\n/g, "; ")
}
-31
View File
@@ -1,31 +0,0 @@
import type { Platform } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import { startBackgroundCheck } from "./session-notification-utils"
export function createSessionNotificationInit() {
let platform: Platform | null = null
let defaultSoundPath: string | null = null
let started = false
function initialize(): { platform: Platform; defaultSoundPath: string } {
if (!platform) {
platform = sessionNotificationSender.detectPlatform()
}
if (!defaultSoundPath) {
defaultSoundPath = sessionNotificationSender.getDefaultSoundPath(platform)
}
if (!started) {
startBackgroundCheck(platform)
started = true
}
return {
platform,
defaultSoundPath,
}
}
return {
initialize,
}
}
@@ -1,145 +0,0 @@
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
const { createSessionNotification } = require("./session-notification")
const { setMainSession, subagentSessions, _resetForTesting } = require("../features/claude-code-session-state")
const utils = require("./session-notification-utils")
const sender = require("./session-notification-sender")
describe("session-notification input-needed events", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
}
}
beforeEach(() => {
_resetForTesting()
notificationCalls = []
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
spyOn(sender, "sendSessionNotification").mockImplementation(async (_ctx: unknown, _platform: unknown, _title: unknown, message: string) => {
notificationCalls.push(message)
})
})
afterEach(() => {
subagentSessions.clear()
_resetForTesting()
})
test("sends question notification when question tool asks for input", async () => {
const sessionID = "main-question"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
await hook({
event: {
type: "tool.execute.before",
properties: {
sessionID,
tool: "question",
args: {
questions: [
{
question: "Which branch should we use?",
options: [{ label: "main" }, { label: "dev" }],
},
],
},
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent is asking a question")
})
test("sends permission notification for permission events", async () => {
const sessionID = "main-permission"
setMainSession(sessionID)
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
await hook({
event: {
type: "permission.asked",
properties: {
sessionID,
},
},
})
expect(notificationCalls).toHaveLength(1)
expect(notificationCalls[0]).toContain("Agent needs permission to continue")
})
test("lazily detects platform and starts background checks on first idle event", async () => {
const sessionID = "main-idle"
setMainSession(sessionID)
const detectPlatformSpy = spyOn(sender, "detectPlatform")
detectPlatformSpy.mockReturnValue("darwin")
const getDefaultSoundPathSpy = spyOn(sender, "getDefaultSoundPath")
getDefaultSoundPathSpy.mockReturnValue("/System/Library/Sounds/Glass.aiff")
const startBackgroundCheckSpy = spyOn(utils, "startBackgroundCheck")
startBackgroundCheckSpy.mockImplementation(() => {})
// given
const hook = createSessionNotification(createMockPluginInput(), { enforceMainSessionFilter: false })
// when
await hook({
event: {
type: "session.idle",
properties: {
sessionID,
},
},
})
// then
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
// when
await hook({
event: {
type: "session.idle",
properties: {
sessionID,
},
},
})
// then
expect(detectPlatformSpy).toHaveBeenCalledTimes(1)
expect(getDefaultSoundPathSpy).toHaveBeenCalledTimes(1)
expect(startBackgroundCheckSpy).toHaveBeenCalledTimes(1)
})
})
export {}
-188
View File
@@ -1,188 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { Platform } from "./session-notification-sender"
type SessionNotificationConfig = {
playSound: boolean
soundPath: string
idleConfirmationDelay: number
skipIfIncompleteTodos: boolean
maxTrackedSessions: number
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createIdleNotificationScheduler(options: {
ctx: PluginInput
platform: Platform
config: SessionNotificationConfig
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
send: (ctx: PluginInput, platform: Platform, sessionID: string) => Promise<void>
playSound: (ctx: PluginInput, platform: Platform, soundPath: string) => Promise<void>
}) {
const notifiedSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
const sessionActivitySinceIdle = new Set<string>()
const notificationVersions = new Map<string, number>()
const executingNotifications = new Set<string>()
const scheduledAt = new Map<string, number>()
const activityGracePeriodMs = options.config.activityGracePeriodMs ?? 100
function cleanupOldSessions(): void {
const maxSessions = options.config.maxTrackedSessions
if (notifiedSessions.size > maxSessions) {
const sessionsToRemove = Array.from(notifiedSessions).slice(0, notifiedSessions.size - maxSessions)
sessionsToRemove.forEach((id) => {
notifiedSessions.delete(id)
})
}
if (sessionActivitySinceIdle.size > maxSessions) {
const sessionsToRemove = Array.from(sessionActivitySinceIdle).slice(0, sessionActivitySinceIdle.size - maxSessions)
sessionsToRemove.forEach((id) => {
sessionActivitySinceIdle.delete(id)
})
}
if (notificationVersions.size > maxSessions) {
const sessionsToRemove = Array.from(notificationVersions.keys()).slice(0, notificationVersions.size - maxSessions)
sessionsToRemove.forEach((id) => {
notificationVersions.delete(id)
})
}
if (executingNotifications.size > maxSessions) {
const sessionsToRemove = Array.from(executingNotifications).slice(0, executingNotifications.size - maxSessions)
sessionsToRemove.forEach((id) => {
executingNotifications.delete(id)
})
}
if (scheduledAt.size > maxSessions) {
const sessionsToRemove = Array.from(scheduledAt.keys()).slice(0, scheduledAt.size - maxSessions)
sessionsToRemove.forEach((id) => {
scheduledAt.delete(id)
})
}
}
function cancelPendingNotification(sessionID: string): void {
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
scheduledAt.delete(sessionID)
sessionActivitySinceIdle.add(sessionID)
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
}
function markSessionActivity(sessionID: string): void {
const scheduledTime = scheduledAt.get(sessionID)
if (
activityGracePeriodMs > 0 &&
scheduledTime !== undefined &&
Date.now() - scheduledTime <= activityGracePeriodMs
) {
return
}
cancelPendingNotification(sessionID)
if (!executingNotifications.has(sessionID)) {
notifiedSessions.delete(sessionID)
}
}
async function executeNotification(sessionID: string, version: number): Promise<void> {
if (executingNotifications.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notificationVersions.get(sessionID) !== version) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
if (notifiedSessions.has(sessionID)) {
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
return
}
executingNotifications.add(sessionID)
try {
if (options.config.skipIfIncompleteTodos) {
const hasPendingWork = await options.hasIncompleteTodos(options.ctx, sessionID)
if (notificationVersions.get(sessionID) !== version) {
return
}
if (hasPendingWork) return
}
if (notificationVersions.get(sessionID) !== version) {
return
}
if (sessionActivitySinceIdle.has(sessionID)) {
sessionActivitySinceIdle.delete(sessionID)
return
}
notifiedSessions.add(sessionID)
await options.send(options.ctx, options.platform, sessionID)
if (options.config.playSound && options.config.soundPath) {
await options.playSound(options.ctx, options.platform, options.config.soundPath)
}
} finally {
executingNotifications.delete(sessionID)
pendingTimers.delete(sessionID)
scheduledAt.delete(sessionID)
if (sessionActivitySinceIdle.has(sessionID)) {
notifiedSessions.delete(sessionID)
sessionActivitySinceIdle.delete(sessionID)
}
}
}
function scheduleIdleNotification(sessionID: string): void {
if (notifiedSessions.has(sessionID)) return
if (pendingTimers.has(sessionID)) return
if (executingNotifications.has(sessionID)) return
sessionActivitySinceIdle.delete(sessionID)
scheduledAt.set(sessionID, Date.now())
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
notificationVersions.set(sessionID, currentVersion)
const timer = setTimeout(() => {
executeNotification(sessionID, currentVersion)
}, options.config.idleConfirmationDelay)
pendingTimers.set(sessionID, timer)
cleanupOldSessions()
}
function deleteSession(sessionID: string): void {
cancelPendingNotification(sessionID)
notifiedSessions.delete(sessionID)
sessionActivitySinceIdle.delete(sessionID)
notificationVersions.delete(sessionID)
executingNotifications.delete(sessionID)
scheduledAt.delete(sessionID)
}
return {
markSessionActivity,
scheduleIdleNotification,
deleteSession,
}
}
@@ -1,345 +0,0 @@
import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"
import * as sender from "./session-notification-sender"
import * as utils from "./session-notification-utils"
import type { PluginInput } from "@opencode-ai/plugin"
function createShellPromise(handler: (cmdStr: string) => void) {
return (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
handler(cmdStr)
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
promise.quiet = () => promise
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => p
p.nothrow = () => p
return p
}
return promise
}
}
function createThrowingShellPromise(shouldThrow: (cmdStr: string) => boolean) {
return (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
if (shouldThrow(cmdStr)) {
const err = Object.assign(new Error("command failed"), result)
const rejectedPromise = Promise.reject(err) as Promise<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
rejectedPromise.quiet = () => rejectedPromise
rejectedPromise.nothrow = () => {
const p = Promise.resolve(result) as typeof rejectedPromise
p.quiet = () => p
p.nothrow = () => p
return p
}
return rejectedPromise
}
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => Promise<typeof result> & { quiet: () => Promise<typeof result> }
}
promise.quiet = () => promise
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => p
p.nothrow = () => p
return p
}
return promise
}
}
describe("session-notification-sender", () => {
beforeEach(() => {
jest.restoreAllMocks()
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
})
describe("#given sendSessionNotification", () => {
describe("#when calling ctx.$ for notifications", () => {
test("#then should call .quiet() on all shell commands to suppress stdout/stderr", async () => {
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => Promise<typeof result>
nothrow: () => typeof promise
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => promise
return promise
},
} as unknown as PluginInput
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
expect(quietCalls.length).toBeGreaterThanOrEqual(1)
expect(quietCalls[0]).toContain("terminal-notifier")
})
test("#then should call .quiet() on osascript fallback", async () => {
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null)
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.sendSessionNotification(mockCtx, "darwin", "Test", "Message")
expect(quietCalls.length).toBeGreaterThanOrEqual(1)
expect(quietCalls[0]).toContain("osascript")
})
test("#then should call .quiet() on linux notify-send", async () => {
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.sendSessionNotification(mockCtx, "linux", "Test", "Message")
expect(quietCalls.length).toBe(1)
expect(quietCalls[0]).toContain("notify-send")
})
test("#then should call .quiet() on win32 powershell", async () => {
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.sendSessionNotification(mockCtx, "win32", "Test", "Message")
expect(quietCalls.length).toBe(1)
expect(quietCalls[0]).toContain("powershell")
})
})
})
describe("#given playSessionNotificationSound", () => {
describe("#when calling ctx.$ for sound playback", () => {
test("#then should call .quiet() on darwin afplay", async () => {
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.playSessionNotificationSound(mockCtx, "darwin", "/sound.aiff")
expect(quietCalls.length).toBe(1)
expect(quietCalls[0]).toContain("afplay")
})
test("#then should call .quiet() on linux paplay", async () => {
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga")
expect(quietCalls.length).toBe(1)
expect(quietCalls[0]).toContain("paplay")
})
test("#then should call .quiet() on linux aplay fallback", async () => {
spyOn(utils, "getPaplayPath").mockResolvedValue(null)
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.playSessionNotificationSound(mockCtx, "linux", "/sound.oga")
expect(quietCalls.length).toBe(1)
expect(quietCalls[0]).toContain("aplay")
})
test("#then should call .quiet() on win32 powershell sound", async () => {
const quietCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray, ...values: unknown[]) => {
const cmdStr = cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
const result = { stdout: Buffer.from(""), stderr: Buffer.from(""), exitCode: 0 }
const promise = Promise.resolve(result) as Promise<typeof result> & {
quiet: () => typeof promise
nothrow: () => typeof promise & { quiet: () => typeof promise }
}
promise.quiet = () => {
quietCalls.push(cmdStr)
return promise
}
promise.nothrow = () => {
const p = Promise.resolve(result) as typeof promise
p.quiet = () => {
quietCalls.push(cmdStr)
return p
}
p.nothrow = () => p
return p
}
return promise
},
} as unknown as PluginInput
await sender.playSessionNotificationSound(mockCtx, "win32", "C:\\sound.wav")
expect(quietCalls.length).toBe(1)
expect(quietCalls[0]).toContain("powershell")
})
})
})
})
-117
View File
@@ -1,117 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { platform } from "os"
import {
getOsascriptPath,
getNotifySendPath,
getPowershellPath,
getAfplayPath,
getPaplayPath,
getAplayPath,
getTerminalNotifierPath,
} from "./session-notification-utils"
import { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting"
export type Platform = "darwin" | "linux" | "win32" | "unsupported"
export function detectPlatform(): Platform {
const detected = platform()
if (detected === "darwin" || detected === "linux" || detected === "win32") return detected
return "unsupported"
}
export function getDefaultSoundPath(platform: Platform): string {
switch (platform) {
case "darwin":
return "/System/Library/Sounds/Glass.aiff"
case "linux":
return "/usr/share/sounds/freedesktop/stereo/complete.oga"
case "win32":
return "C:\\Windows\\Media\\notify.wav"
default:
return ""
}
}
export async function sendSessionNotification(
ctx: PluginInput,
platform: Platform,
title: string,
message: string
): Promise<void> {
switch (platform) {
case "darwin": {
// Try terminal-notifier first - deterministic click-to-focus
const terminalNotifierPath = await getTerminalNotifierPath()
if (terminalNotifierPath) {
const bundleId = process.env.__CFBundleIdentifier
try {
if (bundleId) {
await ctx.$`${terminalNotifierPath} -title ${title} -message ${message} -activate ${bundleId}`.quiet()
} else {
await ctx.$`${terminalNotifierPath} -title ${title} -message ${message}`.quiet()
}
break
} catch {
}
}
// Fallback: osascript (click may open Finder instead of terminal)
const osascriptPath = await getOsascriptPath()
if (!osascriptPath) return
const escapedTitle = escapeAppleScriptText(title)
const escapedMessage = escapeAppleScriptText(message)
await ctx.$`${osascriptPath} -e ${"display notification \"" + escapedMessage + "\" with title \"" + escapedTitle + "\""}`.nothrow().quiet()
break
}
case "linux": {
const notifySendPath = await getNotifySendPath()
if (!notifySendPath) return
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.nothrow().quiet()
break
}
case "win32": {
const powershellPath = await getPowershellPath()
if (!powershellPath) return
const toastScript = buildWindowsToastScript(title, message)
await ctx.$`${powershellPath} -Command ${toastScript}`.nothrow().quiet()
break
}
}
}
export async function playSessionNotificationSound(
ctx: PluginInput,
platform: Platform,
soundPath: string
): Promise<void> {
switch (platform) {
case "darwin": {
const afplayPath = await getAfplayPath()
if (!afplayPath) return
ctx.$`${afplayPath} ${soundPath}`.nothrow().quiet()
break
}
case "linux": {
const paplayPath = await getPaplayPath()
if (paplayPath) {
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet()
} else {
const aplayPath = await getAplayPath()
if (aplayPath) {
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.nothrow().quiet()
}
}
break
}
case "win32": {
const powershellPath = await getPowershellPath()
if (!powershellPath) return
const escaped = escapePowerShellSingleQuotedText(soundPath)
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()"}`.nothrow().quiet()
break
}
}
}
-80
View File
@@ -1,80 +0,0 @@
import { log } from "../shared/logger"
declare const Bun: {
which(commandName: string): string | null
}
type Platform = "darwin" | "linux" | "win32" | "unsupported"
async function findCommand(commandName: string): Promise<string | null> {
try {
return Bun.which(commandName)
} catch (error) {
log("[session-notification] failed to resolve command path", {
commandName,
error: error instanceof Error ? error.message : String(error),
})
return null
}
}
function logBackgroundCheckError(commandName: string, error: unknown): void {
log("[session-notification] background command check failed", {
commandName,
error: error instanceof Error ? error.message : String(error),
})
}
function createCommandFinder(commandName: string): () => Promise<string | null> {
let cachedPath: string | null = null
let pending: Promise<string | null> | null = null
return async () => {
if (cachedPath !== null) return cachedPath
if (pending) return pending
pending = (async () => {
const path = await findCommand(commandName)
cachedPath = path
return path
})()
return pending
}
}
export const getNotifySendPath = createCommandFinder("notify-send")
export const getOsascriptPath = createCommandFinder("osascript")
export const getPowershellPath = createCommandFinder("powershell")
export const getAfplayPath = createCommandFinder("afplay")
export const getPaplayPath = createCommandFinder("paplay")
export const getAplayPath = createCommandFinder("aplay")
export const getTerminalNotifierPath = createCommandFinder("terminal-notifier")
export function startBackgroundCheck(platform: Platform): void {
if (platform === "darwin") {
getOsascriptPath().catch((error) => {
logBackgroundCheckError("osascript", error)
})
getAfplayPath().catch((error) => {
logBackgroundCheckError("afplay", error)
})
getTerminalNotifierPath().catch((error) => {
logBackgroundCheckError("terminal-notifier", error)
})
} else if (platform === "linux") {
getNotifySendPath().catch((error) => {
logBackgroundCheckError("notify-send", error)
})
getPaplayPath().catch((error) => {
logBackgroundCheckError("paplay", error)
})
getAplayPath().catch((error) => {
logBackgroundCheckError("aplay", error)
})
} else if (platform === "win32") {
getPowershellPath().catch((error) => {
logBackgroundCheckError("powershell", error)
})
}
}
-637
View File
@@ -1,637 +0,0 @@
import { afterEach, beforeEach, describe, expect, jest, spyOn, test } from "bun:test"
import { createSessionNotification } from "./session-notification"
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
import * as utils from "./session-notification-utils"
import * as sender from "./session-notification-sender"
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
const originalDateNow = Date.now
describe("session-notification", () => {
let notificationCalls: string[]
function createMockPluginInput() {
return {
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
// given - track notification commands (osascript, notify-send, powershell)
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
notificationCalls.push(cmdStr)
}
return { stdout: "", stderr: "", exitCode: 0 }
},
client: {
session: {
todo: async () => ({ data: [] }),
},
},
directory: "/tmp/test",
} as any
}
beforeEach(() => {
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
_resetForTesting()
notificationCalls = []
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
spyOn(sender, "detectPlatform").mockReturnValue("darwin")
spyOn(sender, "sendSessionNotification").mockImplementation(
async (
_ctx: Parameters<typeof sender.sendSessionNotification>[0],
_platform: Parameters<typeof sender.sendSessionNotification>[1],
_title: Parameters<typeof sender.sendSessionNotification>[2],
message: Parameters<typeof sender.sendSessionNotification>[3]
) => {
notificationCalls.push(message)
}
)
})
afterEach(() => {
// given - cleanup after each test
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
subagentSessions.clear()
_resetForTesting()
})
test("should not trigger notification for subagent session", async () => {
// given - a subagent session exists
const subagentSessionID = "subagent-123"
subagentSessions.add(subagentSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// when - subagent session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// then - notification should NOT be sent
expect(notificationCalls).toHaveLength(0)
})
test("should not trigger notification when mainSessionID is set and session is not main", async () => {
// given - main session is set, but a different session goes idle
const mainSessionID = "main-123"
const otherSessionID = "other-456"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// when - non-main session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: otherSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// then - notification should NOT be sent
expect(notificationCalls).toHaveLength(0)
})
test("should trigger notification for main session when idle", async () => {
// given - main session is set
const mainSessionID = "main-789"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
})
// when - main session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// Wait for idle confirmation delay + buffer
await new Promise((resolve) => setTimeout(resolve, 100))
// then - notification should be sent
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
})
test("should skip notification for subagent even when mainSessionID is set", async () => {
// given - both mainSessionID and subagent session exist
const mainSessionID = "main-999"
const subagentSessionID = "subagent-888"
setMainSession(mainSessionID)
subagentSessions.add(subagentSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// when - subagent session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// then - notification should NOT be sent (subagent check takes priority)
expect(notificationCalls).toHaveLength(0)
})
test("should handle subagentSessions and mainSessionID checks in correct order", async () => {
// given - main session and subagent session exist
const mainSessionID = "main-111"
const subagentSessionID = "subagent-222"
const unknownSessionID = "unknown-333"
setMainSession(mainSessionID)
subagentSessions.add(subagentSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 0,
})
// when - subagent session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// when - unknown session goes idle (not main, not in subagentSessions)
await hook({
event: {
type: "session.idle",
properties: { sessionID: unknownSessionID },
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// then - no notifications (subagent blocked by subagentSessions, unknown blocked by mainSessionID check)
expect(notificationCalls).toHaveLength(0)
})
test("should cancel pending notification on session activity", async () => {
// given - main session is set
const mainSessionID = "main-cancel"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 100,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// when - activity happens before delay completes
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 150))
// then - notification should NOT be sent (cancelled by activity)
expect(notificationCalls).toHaveLength(0)
})
test("should handle session.created event without notification", async () => {
// given - a new session is created
const hook = createSessionNotification(createMockPluginInput(), {})
// when - session.created event fires
await hook({
event: {
type: "session.created",
properties: {
info: { id: "new-session", title: "Test Session" },
},
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// then - no notification should be triggered
expect(notificationCalls).toHaveLength(0)
})
test("should handle session.deleted event and cleanup state", async () => {
// given - a session exists
const hook = createSessionNotification(createMockPluginInput(), {})
// when - session.deleted event fires
await hook({
event: {
type: "session.deleted",
properties: {
info: { id: "deleted-session" },
},
},
})
// Wait for any pending timers
await new Promise((resolve) => setTimeout(resolve, 50))
// then - no notification should be triggered
expect(notificationCalls).toHaveLength(0)
})
test("should mark session activity on message.updated event", async () => {
// given - main session is set
const mainSessionID = "main-message"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then message.updated fires
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
await hook({
event: {
type: "message.updated",
properties: {
info: { sessionID: mainSessionID, role: "user", finish: false },
},
},
})
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// then - notification should NOT be sent (activity cancelled it)
expect(notificationCalls).toHaveLength(0)
})
test("should mark session activity on tool.execute.before event", async () => {
// given - main session is set
const mainSessionID = "main-tool"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 0,
})
// when - session goes idle, then tool.execute.before fires
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID: mainSessionID },
},
})
// Wait for idle delay to pass
await new Promise((resolve) => setTimeout(resolve, 100))
// then - notification should NOT be sent (activity cancelled it)
expect(notificationCalls).toHaveLength(0)
})
test("should not send duplicate notification for same session", async () => {
// given - main session is set
const mainSessionID = "main-dup"
setMainSession(mainSessionID)
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 10,
skipIfIncompleteTodos: false,
enforceMainSessionFilter: false,
})
// when - session goes idle twice
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// Wait for first notification
await new Promise((resolve) => setTimeout(resolve, 50))
await hook({
event: {
type: "session.idle",
properties: { sessionID: mainSessionID },
},
})
// Wait for second potential notification
await new Promise((resolve) => setTimeout(resolve, 50))
// then - only one notification should be sent
expect(notificationCalls).toHaveLength(1)
})
function createSenderMockCtx() {
const notifyCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray | string, ...values: any[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
notifyCalls.push(cmdStr)
const result = { stdout: "", stderr: "", exitCode: 0 }
const promise = Promise.resolve(result) as any
promise.quiet = () => promise
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return promise
},
} as any
return { mockCtx, notifyCalls }
}
test("should use terminal-notifier with -activate when available on darwin", async () => {
// given - terminal-notifier is available and __CFBundleIdentifier is set
spyOn(sender, "sendSessionNotification").mockRestore()
const { mockCtx, notifyCalls } = createSenderMockCtx()
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
const originalEnv = process.env.__CFBundleIdentifier
process.env.__CFBundleIdentifier = "com.mitchellh.ghostty"
try {
// when - sendSessionNotification is called directly on darwin
await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message")
// then - notification uses terminal-notifier with -activate flag
expect(notifyCalls.length).toBeGreaterThanOrEqual(1)
const tnCall = notifyCalls.find(c => c.includes("terminal-notifier"))
expect(tnCall).toBeDefined()
expect(tnCall).toContain("-activate")
expect(tnCall).toContain("com.mitchellh.ghostty")
} finally {
if (originalEnv !== undefined) {
process.env.__CFBundleIdentifier = originalEnv
} else {
delete process.env.__CFBundleIdentifier
}
}
})
test("should fall back to osascript when terminal-notifier is not available", async () => {
// given - terminal-notifier is NOT available
spyOn(sender, "sendSessionNotification").mockRestore()
const { mockCtx, notifyCalls } = createSenderMockCtx()
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null)
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
// when - sendSessionNotification is called directly on darwin
await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message")
// then - notification uses osascript (fallback)
expect(notifyCalls.length).toBeGreaterThanOrEqual(1)
const osascriptCall = notifyCalls.find(c => c.includes("osascript"))
expect(osascriptCall).toBeDefined()
const tnCall = notifyCalls.find(c => c.includes("terminal-notifier"))
expect(tnCall).toBeUndefined()
})
test("should fall back to osascript when terminal-notifier execution fails", async () => {
// given - terminal-notifier exists but invocation fails
spyOn(sender, "sendSessionNotification").mockRestore()
const notifyCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
const cmdStr = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
notifyCalls.push(cmdStr)
if (cmdStr.includes("terminal-notifier")) {
const err = Object.assign(new Error("terminal-notifier failed"), { stdout: "", stderr: "", exitCode: 1 })
const rejected = Promise.reject(err) as any
rejected.quiet = () => rejected
rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return rejected
}
const result = { stdout: "", stderr: "", exitCode: 0 }
const promise = Promise.resolve(result) as any
promise.quiet = () => promise
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return promise
},
} as any
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
// when - sendSessionNotification is called directly on darwin
await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message")
// then - osascript fallback should be attempted after terminal-notifier failure
const tnCall = notifyCalls.find(c => c.includes("terminal-notifier"))
const osascriptCall = notifyCalls.find(c => c.includes("osascript"))
expect(tnCall).toBeDefined()
expect(osascriptCall).toBeDefined()
})
test("should invoke terminal-notifier without array interpolation", async () => {
// given - shell interpolation rejects array values
spyOn(sender, "sendSessionNotification").mockRestore()
const notifyCalls: string[] = []
const mockCtx = {
$: (cmd: TemplateStringsArray | string, ...values: unknown[]) => {
if (values.some(Array.isArray)) {
const err = Object.assign(new Error("array interpolation unsupported"), { stdout: "", stderr: "", exitCode: 1 })
const rejected = Promise.reject(err) as any
rejected.quiet = () => rejected
rejected.nothrow = () => { const p = Promise.resolve({ stdout: "", stderr: "", exitCode: 1 }) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return rejected
}
const commandString = typeof cmd === "string"
? cmd
: cmd.reduce((acc, part, index) => `${acc}${part}${String(values[index] ?? "")}`, "")
notifyCalls.push(commandString)
const result = { stdout: "", stderr: "", exitCode: 0 }
const promise = Promise.resolve(result) as any
promise.quiet = () => promise
promise.nothrow = () => { const p = Promise.resolve(result) as any; p.quiet = () => p; p.nothrow = () => p; return p }
return promise
},
} as any
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
// when - terminal-notifier command is executed
await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message")
// then - terminal-notifier succeeds directly and fallback is not used
const tnCall = notifyCalls.find(c => c.includes("terminal-notifier"))
const osascriptCall = notifyCalls.find(c => c.includes("osascript"))
expect(tnCall).toBeDefined()
expect(osascriptCall).toBeUndefined()
})
test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => {
// given - terminal-notifier available but no bundle ID
spyOn(sender, "sendSessionNotification").mockRestore()
const { mockCtx, notifyCalls } = createSenderMockCtx()
spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier")
const originalEnv = process.env.__CFBundleIdentifier
delete process.env.__CFBundleIdentifier
try {
// when - sendSessionNotification is called directly on darwin
await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message")
// then - terminal-notifier used but without -activate flag
expect(notifyCalls.length).toBeGreaterThanOrEqual(1)
const tnCall = notifyCalls.find(c => c.includes("terminal-notifier"))
expect(tnCall).toBeDefined()
expect(tnCall).not.toContain("-activate")
} finally {
if (originalEnv !== undefined) {
process.env.__CFBundleIdentifier = originalEnv
}
}
})
test("should ignore activity events within grace period", async () => {
jest.useFakeTimers()
jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z"))
try {
// given - a regular session notification is scheduled
const sessionID = "main-grace"
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 50,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 100,
enforceMainSessionFilter: false,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// when - activity happens immediately (within grace period)
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID },
},
})
// when - idle confirmation delay passes deterministically
jest.advanceTimersByTime(50)
jest.runOnlyPendingTimers()
await Promise.resolve()
// then - notification SHOULD be sent (activity was within grace period, ignored)
expect(notificationCalls.length).toBeGreaterThanOrEqual(1)
} finally {
jest.clearAllTimers()
jest.useRealTimers()
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
Date.now = originalDateNow
}
})
test("should cancel notification for activity after grace period", async () => {
// given - a regular session notification is scheduled
const sessionID = "main-grace-cancel"
const hook = createSessionNotification(createMockPluginInput(), {
idleConfirmationDelay: 200,
skipIfIncompleteTodos: false,
activityGracePeriodMs: 50,
enforceMainSessionFilter: false,
})
// when - session goes idle
await hook({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// when - wait for grace period to pass
await new Promise((resolve) => setTimeout(resolve, 60))
// when - activity happens after grace period
await hook({
event: {
type: "tool.execute.before",
properties: { sessionID },
},
})
// Wait for original delay to pass
await new Promise((resolve) => setTimeout(resolve, 200))
// then - notification should NOT be sent (activity cancelled it after grace period)
expect(notificationCalls).toHaveLength(0)
})
})
-169
View File
@@ -1,169 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
import { buildReadyNotificationContent } from "./session-notification-content"
import { type Platform } from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import { getEventToolName, getQuestionText, getSessionID } from "./session-notification-event-properties"
import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
import { createSessionNotificationInit } from "./session-notification-init"
interface SessionNotificationConfig {
title?: string
message?: string
questionMessage?: string
permissionMessage?: string
playSound?: boolean
soundPath?: string
/** Delay in ms before sending notification to confirm session is still idle (default: 1500) */
idleConfirmationDelay?: number
/** Skip notification if there are incomplete todos (default: true) */
skipIfIncompleteTodos?: boolean
/** Maximum number of sessions to track before cleanup (default: 100) */
maxTrackedSessions?: number
enforceMainSessionFilter?: boolean
/** Grace period in ms to ignore late-arriving activity events after scheduling (default: 100) */
activityGracePeriodMs?: number
}
export function createSessionNotification(ctx: PluginInput, config: SessionNotificationConfig = {}) {
const mergedConfig = {
title: "OpenCode",
message: "Agent is ready for input",
questionMessage: "Agent is asking a question",
permissionMessage: "Agent needs permission to continue",
playSound: false,
soundPath: "",
idleConfirmationDelay: 1500,
skipIfIncompleteTodos: true,
maxTrackedSessions: 100,
enforceMainSessionFilter: true,
...config,
}
const sessionNotificationInit = createSessionNotificationInit()
let currentPlatform: Platform | null = null
let defaultSoundPath = mergedConfig.soundPath
const scheduler = createIdleNotificationScheduler({
ctx,
platform: "unsupported",
config: mergedConfig,
hasIncompleteTodos,
send: async (hookCtx, platform, sessionID) => {
if (typeof hookCtx.client.session.get !== "function" && typeof hookCtx.client.session.messages !== "function") {
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, mergedConfig.title, mergedConfig.message)
return
}
const content = await buildReadyNotificationContent(hookCtx, {
sessionID,
baseTitle: mergedConfig.title,
baseMessage: mergedConfig.message,
})
await sessionNotificationSender.sendSessionNotification(hookCtx, platform, content.title, content.message)
},
playSound: sessionNotificationSender.playSessionNotificationSound,
})
const QUESTION_TOOLS = new Set(["question", "ask_user_question", "askuserquestion"])
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const ensureNotificationPlatform = (): Platform => {
if (currentPlatform) return currentPlatform
const initialized = sessionNotificationInit.initialize()
currentPlatform = initialized.platform
defaultSoundPath = initialized.defaultSoundPath || mergedConfig.soundPath
return currentPlatform
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
if (mergedConfig.enforceMainSessionFilter) {
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) return false
}
return true
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.created") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.id as string | undefined
if (sessionID) scheduler.markSessionActivity(sessionID)
return
}
if (event.type === "session.idle") {
const sessionID = getSessionID(props)
if (!sessionID) return
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
scheduler.scheduleIdleNotification(sessionID)
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = getSessionID({ ...props, info })
if (sessionID) scheduler.markSessionActivity(sessionID)
return
}
if (PERMISSION_EVENTS.has(event.type)) {
const sessionID = getSessionID(props)
if (!sessionID) return
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
scheduler.markSessionActivity(sessionID)
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, mergedConfig.permissionMessage)
if (mergedConfig.playSound && defaultSoundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
}
return
}
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = getSessionID(props)
if (sessionID) {
scheduler.markSessionActivity(sessionID)
if (event.type === "tool.execute.before") {
const toolName = getEventToolName(props)?.toLowerCase()
if (toolName && QUESTION_TOOLS.has(toolName)) {
const platform = ensureNotificationPlatform()
if (platform === "unsupported") return
if (!shouldNotifyForSession(sessionID)) return
const questionText = getQuestionText(props)
const message = PERMISSION_HINT_PATTERN.test(questionText) ? mergedConfig.permissionMessage : mergedConfig.questionMessage
await sessionNotificationSender.sendSessionNotification(ctx, platform, mergedConfig.title, message)
if (mergedConfig.playSound && defaultSoundPath) {
await sessionNotificationSender.playSessionNotificationSound(ctx, platform, defaultSoundPath)
}
}
}
}
return
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) scheduler.deleteSession(sessionInfo.id)
}
}
}