Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
137 changes: 137 additions & 0 deletions src/hooks/session-notification-format.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
25 changes: 25 additions & 0 deletions src/hooks/session-notification-format.ts
Original file line number Diff line number Diff line change
@@ -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)
}
124 changes: 124 additions & 0 deletions src/hooks/session-notification-platform.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<boolean> {
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
}
}
17 changes: 16 additions & 1 deletion src/hooks/session-notification-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
try {
Expand Down Expand Up @@ -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<string> | Map<string, unknown>)[]
) {
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(() => {})
Expand Down
Loading