diff --git a/docs/configurations.md b/docs/configurations.md index 2fb67bd475..a65f199c02 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -1008,14 +1008,16 @@ Configure notification behavior for background task completion. ```json { "notification": { - "force_enable": true + "force_enable": true, + "message_format": "{project} — Agent is ready for input" } } ``` -| Option | Default | Description | -| -------------- | ------- | ---------------------------------------------------------------------------------------------- | -| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. | +| Option | Default | Description | +| ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. | +| `message_format` | `"{project} — Agent is ready for input"` | Custom message format for OS notifications. Supports template variables: `{project}` (folder name) and `{cwd}` (full working directory path). Unrecognized variables are left as-is. | ## Sisyphus Tasks diff --git a/src/config/schema.ts b/src/config/schema.ts index 34ec376be9..5a2afbf570 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -333,6 +333,8 @@ export const BackgroundTaskConfigSchema = z.object({ export const NotificationConfigSchema = z.object({ /** Force enable session-notification even if external notification plugins are detected (default: false) */ force_enable: z.boolean().optional(), + /** Custom message format with template variables: {project} (folder name), {cwd} (full path). Default: "{project} — Agent is ready for input" */ + message_format: z.string().optional(), }) export const BabysittingConfigSchema = z.object({ diff --git a/src/hooks/session-notification-format.test.ts b/src/hooks/session-notification-format.test.ts new file mode 100644 index 0000000000..419285ea16 --- /dev/null +++ b/src/hooks/session-notification-format.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test" + +import { extractProjectName, resolveMessageFormat } from "./session-notification-format" + +describe("session-notification-format", () => { + describe("resolveMessageFormat", () => { + test("should replace {project} variable", () => { + // given - a format string with {project} placeholder + const format = "{project} — Agent is ready" + const vars = { project: "my-app", cwd: "/home/user/my-app" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - {project} should be replaced with the project value + expect(result).toBe("my-app — Agent is ready") + }) + + test("should replace {cwd} variable", () => { + // given - a format string with {cwd} placeholder + const format = "{cwd} is idle" + const vars = { project: "app", cwd: "/full/path/app" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - {cwd} should be replaced with the cwd value + expect(result).toBe("/full/path/app is idle") + }) + + test("should replace both {project} and {cwd} variables", () => { + // given - a format string with both placeholders + const format = "Both {project} and {cwd}" + const vars = { project: "p", cwd: "/c" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - both variables should be replaced + expect(result).toBe("Both p and /c") + }) + + test("should leave strings without variables unchanged", () => { + // given - a format string with no placeholders + const format = "No variables here" + const vars = { project: "p", cwd: "/c" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - the string should remain unchanged + expect(result).toBe("No variables here") + }) + + test("should leave unrecognized variables as-is", () => { + // given - a format string with an unrecognized variable + const format = "{unknown} stays" + const vars = { project: "p", cwd: "/c" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - unrecognized variables should pass through unchanged + expect(result).toBe("{unknown} stays") + }) + + test("should replace all occurrences of repeated variables", () => { + // given - a format string with repeated {project} placeholders + const format = "{project} — {project} is ready" + const vars = { project: "my-app", cwd: "/home/user/my-app" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - all occurrences should be replaced + expect(result).toBe("my-app — my-app is ready") + }) + + test("should handle empty format string", () => { + // given - an empty format string + const format = "" + const vars = { project: "p", cwd: "/c" } + + // when - resolving the format + const result = resolveMessageFormat(format, vars) + + // then - should return empty string + expect(result).toBe("") + }) + }) + + describe("extractProjectName", () => { + test("should extract project name from directory path", () => { + // given - a directory path + const directory = "/home/user/my-project" + + // when - extracting the project name + const result = extractProjectName(directory) + + // then - should return the last path component + expect(result).toBe("my-project") + }) + + test("should return empty string for root path", () => { + // given - the root path + const directory = "/" + + // when - extracting the project name + const result = extractProjectName(directory) + + // then - should return empty string + expect(result).toBe("") + }) + + test("should return empty string for empty input", () => { + // given - an empty string + const directory = "" + + // when - extracting the project name + const result = extractProjectName(directory) + + // then - should return empty string + expect(result).toBe("") + }) + + test("should handle trailing slash in path", () => { + // given - a directory path with trailing slash + const directory = "/home/user/project/" + + // when - extracting the project name + const result = extractProjectName(directory) + + // then - should return the project name without the slash + expect(result).toBe("project") + }) + }) +}) diff --git a/src/hooks/session-notification-format.ts b/src/hooks/session-notification-format.ts new file mode 100644 index 0000000000..ecc0ff5372 --- /dev/null +++ b/src/hooks/session-notification-format.ts @@ -0,0 +1,25 @@ +import { basename, resolve } from "path" + +/** + * Extracts the project folder name from a directory path. + * Returns empty string for root path "/" or empty input. + */ +export function extractProjectName(directory: string): string { + if (!directory) return "" + const resolved = resolve(directory) + const name = basename(resolved) + return name === "/" ? "" : name +} + +/** + * Resolves template variables {project} and {cwd} in a format string. + * Unrecognized variables (e.g., {unknown}) are left as-is. + */ +export function resolveMessageFormat( + format: string, + vars: { project: string; cwd: string } +): string { + return format + .replaceAll("{project}", vars.project) + .replaceAll("{cwd}", vars.cwd) +} diff --git a/src/hooks/session-notification-platform.ts b/src/hooks/session-notification-platform.ts new file mode 100644 index 0000000000..b1d03dc4b7 --- /dev/null +++ b/src/hooks/session-notification-platform.ts @@ -0,0 +1,124 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { platform } from "os" +import type { Platform } from "./session-notification-utils" +import { + getOsascriptPath, + getNotifySendPath, + getPowershellPath, + getAfplayPath, + getPaplayPath, + getAplayPath, +} from "./session-notification-utils" + +interface Todo { + content: string + status: string + priority: string + id: string +} + +export function detectPlatform(): Platform { + const p = platform() + if (p === "darwin" || p === "linux" || p === "win32") return p + return "unsupported" +} + +export function getDefaultSoundPath(p: Platform): string { + switch (p) { + 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 sendNotification( + ctx: PluginInput, + p: Platform, + title: string, + message: string +): Promise { + switch (p) { + case "darwin": { + const osascriptPath = await getOsascriptPath() + if (!osascriptPath) return + + const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {}) + break + } + case "linux": { + const notifySendPath = await getNotifySendPath() + if (!notifySendPath) return + + await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) + break + } + case "win32": { + const powershellPath = await getPowershellPath() + if (!powershellPath) return + + const psTitle = title.replace(/'/g, "''") + const psMessage = message.replace(/'/g, "''") + const toastScript = ` +[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, "; ") + await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) + break + } + } +} + +export async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise { + switch (p) { + case "darwin": { + const afplayPath = await getAfplayPath() + if (!afplayPath) return + ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) + break + } + case "linux": { + const paplayPath = await getPaplayPath() + if (paplayPath) { + ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + } else { + const aplayPath = await getAplayPath() + if (aplayPath) { + ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) + } + } + break + } + case "win32": { + const powershellPath = await getPowershellPath() + if (!powershellPath) return + ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath.replace(/'/g, "''") + "').PlaySync()"}`.catch(() => {}) + break + } + } +} + +export async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { + try { + const response = await ctx.client.session.todo({ path: { id: sessionID } }) + const todos = (response.data ?? response) as Todo[] + if (!todos || todos.length === 0) return false + return todos.some((t) => t.status !== "completed" && t.status !== "cancelled") + } catch { + return false + } +} diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index 81fce465b0..dfbb5cba1e 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -1,6 +1,6 @@ import { spawn } from "bun" -type Platform = "darwin" | "linux" | "win32" | "unsupported" +export type Platform = "darwin" | "linux" | "win32" | "unsupported" async function findCommand(commandName: string): Promise { try { @@ -35,6 +35,21 @@ export const getAfplayPath = createCommandFinder("afplay") export const getPaplayPath = createCommandFinder("paplay") export const getAplayPath = createCommandFinder("aplay") +export function cleanupOldSessions( + maxSessions: number, + ...sets: (Set | Map)[] +) { + for (const collection of sets) { + if (collection.size > maxSessions) { + const keys = collection instanceof Map + ? Array.from(collection.keys()) + : Array.from(collection) + const toRemove = keys.slice(0, collection.size - maxSessions) + toRemove.forEach(id => collection.delete(id)) + } + } +} + export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { getOsascriptPath().catch(() => {}) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index 2f0377a4c7..2dfb724cd0 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -258,7 +258,7 @@ describe("session-notification", () => { expect(notificationCalls).toHaveLength(0) }) - test("should mark session activity on message.updated event", async () => { + test("should mark session activity on message.updated with assistant role", async () => { // given - main session is set const mainSessionID = "main-message" setMainSession(mainSessionID) @@ -268,7 +268,7 @@ describe("session-notification", () => { skipIfIncompleteTodos: false, }) - // when - session goes idle, then message.updated fires + // when - session goes idle, then assistant message.updated fires await hook({ event: { type: "session.idle", @@ -280,7 +280,7 @@ describe("session-notification", () => { event: { type: "message.updated", properties: { - info: { sessionID: mainSessionID, role: "user", finish: false }, + info: { sessionID: mainSessionID, role: "assistant", finish: false }, }, }, }) @@ -288,10 +288,44 @@ describe("session-notification", () => { // Wait for idle delay to pass await new Promise((resolve) => setTimeout(resolve, 100)) - // then - notification should NOT be sent (activity cancelled it) + // then - notification should NOT be sent (assistant activity cancelled it) expect(notificationCalls).toHaveLength(0) }) + test("should NOT mark session activity on message.updated with user role", async () => { + // given - main session is set + const mainSessionID = "main-message-user" + setMainSession(mainSessionID) + + const hook = createSessionNotification(createMockPluginInput(), { + idleConfirmationDelay: 50, + skipIfIncompleteTodos: false, + }) + + // when - session goes idle, then user 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 be sent (user message.updated does not cancel timer) + expect(notificationCalls).toHaveLength(1) + }) + test("should mark session activity on tool.execute.before event", async () => { // given - main session is set const mainSessionID = "main-tool" @@ -358,4 +392,55 @@ describe("session-notification", () => { // then - only one notification should be sent expect(notificationCalls).toHaveLength(1) }) + + test("should use default message format with project name", async () => { + // given - a session notification with default config and a project directory + const mockInput = createMockPluginInput() + mockInput.directory = "/home/user/my-awesome-project" + const handler = createSessionNotification(mockInput) + setMainSession("session-format-default") + + // when - session goes idle and notification fires + await handler({ event: { type: "session.idle", properties: { sessionID: "session-format-default" } } }) + await new Promise(resolve => setTimeout(resolve, 2000)) + + // then - notification message should use the default format with project name + expect(notificationCalls).toHaveLength(1) + expect(notificationCalls[0]).toContain("my-awesome-project \u2014 Agent is ready for input") + }) + + test("should resolve custom message_format with {project} and {cwd}", async () => { + // given - a session notification with custom message_format + const mockInput = createMockPluginInput() + mockInput.directory = "/workspace/cool-app" + const handler = createSessionNotification(mockInput, { + message_format: "Done in {project} at {cwd}", + }) + setMainSession("session-format-custom") + + // when - session goes idle and notification fires + await handler({ event: { type: "session.idle", properties: { sessionID: "session-format-custom" } } }) + await new Promise(resolve => setTimeout(resolve, 2000)) + + // then - notification should use the resolved custom format + expect(notificationCalls).toHaveLength(1) + expect(notificationCalls[0]).toContain("Done in cool-app at /workspace/cool-app") + }) + + test("should use literal string when message_format has no variables", async () => { + // given - a session notification with a literal message_format (no template variables) + const mockInput = createMockPluginInput() + const handler = createSessionNotification(mockInput, { + message_format: "Custom static message", + }) + setMainSession("session-format-literal") + + // when - session goes idle and notification fires + await handler({ event: { type: "session.idle", properties: { sessionID: "session-format-literal" } } }) + await new Promise(resolve => setTimeout(resolve, 2000)) + + // then - notification should use the literal string as-is + expect(notificationCalls).toHaveLength(1) + expect(notificationCalls[0]).toContain("Custom static message") + }) }) diff --git a/src/hooks/session-notification.ts b/src/hooks/session-notification.ts index 76b97dc9a7..464b161964 100644 --- a/src/hooks/session-notification.ts +++ b/src/hooks/session-notification.ts @@ -1,26 +1,19 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { platform } from "os" import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state" +import { startBackgroundCheck, cleanupOldSessions } from "./session-notification-utils" import { - getOsascriptPath, - getNotifySendPath, - getPowershellPath, - getAfplayPath, - getPaplayPath, - getAplayPath, - startBackgroundCheck, -} from "./session-notification-utils" - -interface Todo { - content: string - status: string - priority: string - id: string -} + detectPlatform, + getDefaultSoundPath, + sendNotification, + playSound, + hasIncompleteTodos, +} from "./session-notification-platform" +import { extractProjectName, resolveMessageFormat } from "./session-notification-format" interface SessionNotificationConfig { title?: string - message?: string + /** Custom message format with {project} and {cwd} template variables */ + message_format?: string playSound?: boolean soundPath?: string /** Delay in ms before sending notification to confirm session is still idle (default: 1500) */ @@ -31,114 +24,6 @@ interface SessionNotificationConfig { maxTrackedSessions?: number } -type Platform = "darwin" | "linux" | "win32" | "unsupported" - -function detectPlatform(): Platform { - const p = platform() - if (p === "darwin" || p === "linux" || p === "win32") return p - return "unsupported" -} - -function getDefaultSoundPath(p: Platform): string { - switch (p) { - 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 "" - } -} - -async function sendNotification( - ctx: PluginInput, - p: Platform, - title: string, - message: string -): Promise { - switch (p) { - case "darwin": { - const osascriptPath = await getOsascriptPath() - if (!osascriptPath) return - - const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"') - await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {}) - break - } - case "linux": { - const notifySendPath = await getNotifySendPath() - if (!notifySendPath) return - - await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {}) - break - } - case "win32": { - const powershellPath = await getPowershellPath() - if (!powershellPath) return - - const psTitle = title.replace(/'/g, "''") - const psMessage = message.replace(/'/g, "''") - const toastScript = ` -[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, "; ") - await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {}) - break - } - } -} - -async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise { - switch (p) { - case "darwin": { - const afplayPath = await getAfplayPath() - if (!afplayPath) return - ctx.$`${afplayPath} ${soundPath}`.catch(() => {}) - break - } - case "linux": { - const paplayPath = await getPaplayPath() - if (paplayPath) { - ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) - } else { - const aplayPath = await getAplayPath() - if (aplayPath) { - ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {}) - } - } - break - } - case "win32": { - const powershellPath = await getPowershellPath() - if (!powershellPath) return - ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath.replace(/'/g, "''") + "').PlaySync()"}`.catch(() => {}) - break - } - } -} - -async function hasIncompleteTodos(ctx: PluginInput, sessionID: string): Promise { - try { - const response = await ctx.client.session.todo({ path: { id: sessionID } }) - const todos = (response.data ?? response) as Todo[] - if (!todos || todos.length === 0) return false - return todos.some((t) => t.status !== "completed" && t.status !== "cancelled") - } catch { - return false - } -} - export function createSessionNotification( ctx: PluginInput, config: SessionNotificationConfig = {} @@ -150,7 +35,7 @@ export function createSessionNotification( const mergedConfig = { title: "OpenCode", - message: "Agent is ready for input", + message_format: "{project} \u2014 Agent is ready for input", playSound: false, soundPath: defaultSoundPath, idleConfirmationDelay: 1500, @@ -167,24 +52,14 @@ export function createSessionNotification( // Track sessions currently executing notification (prevents duplicate execution) const executingNotifications = new Set() - function cleanupOldSessions() { - const maxSessions = mergedConfig.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)) - } + function cleanupTrackedSessions() { + cleanupOldSessions( + mergedConfig.maxTrackedSessions, + notifiedSessions, + sessionActivitySinceIdle, + notificationVersions, + executingNotifications, + ) } function cancelPendingNotification(sessionID: string) { @@ -240,7 +115,7 @@ export function createSessionNotification( if (notificationVersions.get(sessionID) !== version) { return } - + if (sessionActivitySinceIdle.has(sessionID)) { sessionActivitySinceIdle.delete(sessionID) return @@ -248,7 +123,12 @@ export function createSessionNotification( notifiedSessions.add(sessionID) - await sendNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.message) + const project = extractProjectName(ctx.directory) + const resolvedMessage = resolveMessageFormat( + mergedConfig.message_format, + { project, cwd: ctx.directory } + ) + await sendNotification(ctx, currentPlatform, mergedConfig.title, resolvedMessage) if (mergedConfig.playSound && mergedConfig.soundPath) { await playSound(ctx, currentPlatform, mergedConfig.soundPath) @@ -284,7 +164,6 @@ export function createSessionNotification( if (subagentSessions.has(sessionID)) return - // Only trigger notifications for the main session (not subagent sessions) const mainSessionID = getMainSessionID() if (mainSessionID && sessionID !== mainSessionID) return @@ -302,14 +181,15 @@ export function createSessionNotification( }, mergedConfig.idleConfirmationDelay) pendingTimers.set(sessionID, timer) - cleanupOldSessions() + cleanupTrackedSessions() return } if (event.type === "message.updated") { const info = props?.info as Record | undefined + const role = info?.role as string | undefined const sessionID = info?.sessionID as string | undefined - if (sessionID) { + if (sessionID && role === "assistant") { markSessionActivity(sessionID) } return diff --git a/src/index.ts b/src/index.ts index 20048cfd09..23d6a6f65e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,7 +173,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { allPlugins: externalNotifier.allPlugins, }); } else { - sessionNotification = safeCreateHook("session-notification", () => createSessionNotification(ctx), { enabled: safeHookEnabled }); + sessionNotification = safeCreateHook("session-notification", () => createSessionNotification(ctx, { + message_format: pluginConfig.notification?.message_format, + }), { enabled: safeHookEnabled }); } }