refactor(hooks): split session-notification and unstable-agent-babysitter
Extract notification and babysitter logic: - session-notification-formatting.ts, session-notification-scheduler.ts - session-notification-sender.ts, session-todo-status.ts - task-message-analyzer.ts: message analysis for babysitter hook
This commit is contained in:
parent
2d22a54b55
commit
e4583668c0
@ -1,6 +1,10 @@
|
|||||||
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||||
export { createSessionNotification } from "./session-notification";
|
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 { createSessionRecoveryHook, type SessionRecoveryHook, type SessionRecoveryOptions } from "./session-recovery";
|
||||||
export { createCommentCheckerHooks } from "./comment-checker";
|
export { createCommentCheckerHooks } from "./comment-checker";
|
||||||
export { createToolOutputTruncatorHook } from "./tool-output-truncator";
|
export { createToolOutputTruncatorHook } from "./tool-output-truncator";
|
||||||
|
|||||||
25
src/hooks/session-notification-formatting.ts
Normal file
25
src/hooks/session-notification-formatting.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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, "; ")
|
||||||
|
}
|
||||||
154
src/hooks/session-notification-scheduler.ts
Normal file
154
src/hooks/session-notification-scheduler.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import type { Platform } from "./session-notification-sender"
|
||||||
|
|
||||||
|
type SessionNotificationConfig = {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
playSound: boolean
|
||||||
|
soundPath: string
|
||||||
|
idleConfirmationDelay: number
|
||||||
|
skipIfIncompleteTodos: boolean
|
||||||
|
maxTrackedSessions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIdleNotificationScheduler(options: {
|
||||||
|
ctx: PluginInput
|
||||||
|
platform: Platform
|
||||||
|
config: SessionNotificationConfig
|
||||||
|
hasIncompleteTodos: (ctx: PluginInput, sessionID: string) => Promise<boolean>
|
||||||
|
send: (ctx: PluginInput, platform: Platform, title: string, message: 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>()
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPendingNotification(sessionID: string): void {
|
||||||
|
const timer = pendingTimers.get(sessionID)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
pendingTimers.delete(sessionID)
|
||||||
|
}
|
||||||
|
sessionActivitySinceIdle.add(sessionID)
|
||||||
|
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSessionActivity(sessionID: string): void {
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationVersions.get(sessionID) !== version) {
|
||||||
|
pendingTimers.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionActivitySinceIdle.has(sessionID)) {
|
||||||
|
sessionActivitySinceIdle.delete(sessionID)
|
||||||
|
pendingTimers.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifiedSessions.has(sessionID)) {
|
||||||
|
pendingTimers.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, options.config.title, options.config.message)
|
||||||
|
|
||||||
|
if (options.config.playSound && options.config.soundPath) {
|
||||||
|
await options.playSound(options.ctx, options.platform, options.config.soundPath)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
executingNotifications.delete(sessionID)
|
||||||
|
pendingTimers.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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markSessionActivity,
|
||||||
|
scheduleIdleNotification,
|
||||||
|
deleteSession,
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/hooks/session-notification-sender.ts
Normal file
102
src/hooks/session-notification-sender.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { platform } from "os"
|
||||||
|
import {
|
||||||
|
getOsascriptPath,
|
||||||
|
getNotifySendPath,
|
||||||
|
getPowershellPath,
|
||||||
|
getAfplayPath,
|
||||||
|
getPaplayPath,
|
||||||
|
getAplayPath,
|
||||||
|
} 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": {
|
||||||
|
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 + "\""}`.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 toastScript = buildWindowsToastScript(title, message)
|
||||||
|
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
|
||||||
|
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}`.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
|
||||||
|
const escaped = escapePowerShellSingleQuotedText(soundPath)
|
||||||
|
ctx.$`${powershellPath} -Command ${("(New-Object Media.SoundPlayer '" + escaped + "').PlaySync()")}`.catch(() => {})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,16 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { platform } from "os"
|
|
||||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
getOsascriptPath,
|
|
||||||
getNotifySendPath,
|
|
||||||
getPowershellPath,
|
|
||||||
getAfplayPath,
|
|
||||||
getPaplayPath,
|
|
||||||
getAplayPath,
|
|
||||||
startBackgroundCheck,
|
startBackgroundCheck,
|
||||||
} from "./session-notification-utils"
|
} from "./session-notification-utils"
|
||||||
|
import {
|
||||||
interface Todo {
|
detectPlatform,
|
||||||
content: string
|
getDefaultSoundPath,
|
||||||
status: string
|
playSessionNotificationSound,
|
||||||
priority: string
|
sendSessionNotification,
|
||||||
id: string
|
} from "./session-notification-sender"
|
||||||
}
|
import { hasIncompleteTodos } from "./session-todo-status"
|
||||||
|
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
|
||||||
|
|
||||||
interface SessionNotificationConfig {
|
interface SessionNotificationConfig {
|
||||||
title?: string
|
title?: string
|
||||||
@ -30,115 +24,6 @@ interface SessionNotificationConfig {
|
|||||||
/** Maximum number of sessions to track before cleanup (default: 100) */
|
/** Maximum number of sessions to track before cleanup (default: 100) */
|
||||||
maxTrackedSessions?: number
|
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<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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSessionNotification(
|
export function createSessionNotification(
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
config: SessionNotificationConfig = {}
|
config: SessionNotificationConfig = {}
|
||||||
@ -159,110 +44,14 @@ export function createSessionNotification(
|
|||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifiedSessions = new Set<string>()
|
const scheduler = createIdleNotificationScheduler({
|
||||||
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
ctx,
|
||||||
const sessionActivitySinceIdle = new Set<string>()
|
platform: currentPlatform,
|
||||||
// Track notification execution version to handle race conditions
|
config: mergedConfig,
|
||||||
const notificationVersions = new Map<string, number>()
|
hasIncompleteTodos,
|
||||||
// Track sessions currently executing notification (prevents duplicate execution)
|
send: sendSessionNotification,
|
||||||
const executingNotifications = new Set<string>()
|
playSound: playSessionNotificationSound,
|
||||||
|
})
|
||||||
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 cancelPendingNotification(sessionID: string) {
|
|
||||||
const timer = pendingTimers.get(sessionID)
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
pendingTimers.delete(sessionID)
|
|
||||||
}
|
|
||||||
sessionActivitySinceIdle.add(sessionID)
|
|
||||||
// Increment version to invalidate any in-flight notifications
|
|
||||||
notificationVersions.set(sessionID, (notificationVersions.get(sessionID) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function markSessionActivity(sessionID: string) {
|
|
||||||
cancelPendingNotification(sessionID)
|
|
||||||
if (!executingNotifications.has(sessionID)) {
|
|
||||||
notifiedSessions.delete(sessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeNotification(sessionID: string, version: number) {
|
|
||||||
if (executingNotifications.has(sessionID)) {
|
|
||||||
pendingTimers.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notificationVersions.get(sessionID) !== version) {
|
|
||||||
pendingTimers.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionActivitySinceIdle.has(sessionID)) {
|
|
||||||
sessionActivitySinceIdle.delete(sessionID)
|
|
||||||
pendingTimers.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifiedSessions.has(sessionID)) {
|
|
||||||
pendingTimers.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
executingNotifications.add(sessionID)
|
|
||||||
try {
|
|
||||||
if (mergedConfig.skipIfIncompleteTodos) {
|
|
||||||
const hasPendingWork = await hasIncompleteTodos(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 sendNotification(ctx, currentPlatform, mergedConfig.title, mergedConfig.message)
|
|
||||||
|
|
||||||
if (mergedConfig.playSound && mergedConfig.soundPath) {
|
|
||||||
await playSound(ctx, currentPlatform, mergedConfig.soundPath)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
executingNotifications.delete(sessionID)
|
|
||||||
pendingTimers.delete(sessionID)
|
|
||||||
// Clear notified state if there was activity during notification
|
|
||||||
if (sessionActivitySinceIdle.has(sessionID)) {
|
|
||||||
notifiedSessions.delete(sessionID)
|
|
||||||
sessionActivitySinceIdle.delete(sessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
if (currentPlatform === "unsupported") return
|
if (currentPlatform === "unsupported") return
|
||||||
@ -273,7 +62,7 @@ export function createSessionNotification(
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.id as string | undefined
|
const sessionID = info?.id as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
markSessionActivity(sessionID)
|
scheduler.markSessionActivity(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -288,21 +77,7 @@ export function createSessionNotification(
|
|||||||
const mainSessionID = getMainSessionID()
|
const mainSessionID = getMainSessionID()
|
||||||
if (mainSessionID && sessionID !== mainSessionID) return
|
if (mainSessionID && sessionID !== mainSessionID) return
|
||||||
|
|
||||||
if (notifiedSessions.has(sessionID)) return
|
scheduler.scheduleIdleNotification(sessionID)
|
||||||
if (pendingTimers.has(sessionID)) return
|
|
||||||
if (executingNotifications.has(sessionID)) return
|
|
||||||
|
|
||||||
sessionActivitySinceIdle.delete(sessionID)
|
|
||||||
|
|
||||||
const currentVersion = (notificationVersions.get(sessionID) ?? 0) + 1
|
|
||||||
notificationVersions.set(sessionID, currentVersion)
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
executeNotification(sessionID, currentVersion)
|
|
||||||
}, mergedConfig.idleConfirmationDelay)
|
|
||||||
|
|
||||||
pendingTimers.set(sessionID, timer)
|
|
||||||
cleanupOldSessions()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,7 +85,7 @@ export function createSessionNotification(
|
|||||||
const info = props?.info as Record<string, unknown> | undefined
|
const info = props?.info as Record<string, unknown> | undefined
|
||||||
const sessionID = info?.sessionID as string | undefined
|
const sessionID = info?.sessionID as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
markSessionActivity(sessionID)
|
scheduler.markSessionActivity(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -318,7 +93,7 @@ export function createSessionNotification(
|
|||||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||||
const sessionID = props?.sessionID as string | undefined
|
const sessionID = props?.sessionID as string | undefined
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
markSessionActivity(sessionID)
|
scheduler.markSessionActivity(sessionID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -326,11 +101,7 @@ export function createSessionNotification(
|
|||||||
if (event.type === "session.deleted") {
|
if (event.type === "session.deleted") {
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
if (sessionInfo?.id) {
|
if (sessionInfo?.id) {
|
||||||
cancelPendingNotification(sessionInfo.id)
|
scheduler.deleteSession(sessionInfo.id)
|
||||||
notifiedSessions.delete(sessionInfo.id)
|
|
||||||
sessionActivitySinceIdle.delete(sessionInfo.id)
|
|
||||||
notificationVersions.delete(sessionInfo.id)
|
|
||||||
executingNotifications.delete(sessionInfo.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/hooks/session-todo-status.ts
Normal file
19
src/hooks/session-todo-status.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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((todo) => todo.status !== "completed" && todo.status !== "cancelled")
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1 +1,9 @@
|
|||||||
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter-hook"
|
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter-hook"
|
||||||
|
export {
|
||||||
|
buildReminder,
|
||||||
|
extractMessages,
|
||||||
|
getMessageInfo,
|
||||||
|
getMessageParts,
|
||||||
|
isUnstableTask,
|
||||||
|
THINKING_SUMMARY_MAX_CHARS,
|
||||||
|
} from "./task-message-analyzer"
|
||||||
|
|||||||
91
src/hooks/unstable-agent-babysitter/task-message-analyzer.ts
Normal file
91
src/hooks/unstable-agent-babysitter/task-message-analyzer.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { BackgroundTask } from "../../features/background-agent"
|
||||||
|
|
||||||
|
export const THINKING_SUMMARY_MAX_CHARS = 500 as const
|
||||||
|
|
||||||
|
type MessageInfo = {
|
||||||
|
role?: string
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
providerID?: string
|
||||||
|
modelID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessagePart = {
|
||||||
|
type?: string
|
||||||
|
text?: string
|
||||||
|
thinking?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasData(value: unknown): value is { data?: unknown } {
|
||||||
|
return typeof value === "object" && value !== null && "data" in value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageInfo(value: unknown): MessageInfo | undefined {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
if (!isRecord(value.info)) return undefined
|
||||||
|
const info = value.info
|
||||||
|
const modelValue = isRecord(info.model)
|
||||||
|
? info.model
|
||||||
|
: undefined
|
||||||
|
const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string"
|
||||||
|
? { providerID: modelValue.providerID, modelID: modelValue.modelID }
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
role: typeof info.role === "string" ? info.role : undefined,
|
||||||
|
agent: typeof info.agent === "string" ? info.agent : undefined,
|
||||||
|
model,
|
||||||
|
providerID: typeof info.providerID === "string" ? info.providerID : undefined,
|
||||||
|
modelID: typeof info.modelID === "string" ? info.modelID : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageParts(value: unknown): MessagePart[] {
|
||||||
|
if (!isRecord(value)) return []
|
||||||
|
if (!Array.isArray(value.parts)) return []
|
||||||
|
return value.parts.filter(isRecord).map((part) => ({
|
||||||
|
type: typeof part.type === "string" ? part.type : undefined,
|
||||||
|
text: typeof part.text === "string" ? part.text : undefined,
|
||||||
|
thinking: typeof part.thinking === "string" ? part.thinking : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMessages(value: unknown): unknown[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (hasData(value) && Array.isArray(value.data)) {
|
||||||
|
return value.data
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnstableTask(task: BackgroundTask): boolean {
|
||||||
|
if (task.isUnstableAgent === true) return true
|
||||||
|
const modelId = task.model?.modelID?.toLowerCase()
|
||||||
|
return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string {
|
||||||
|
const idleSeconds = Math.round(idleMs / 1000)
|
||||||
|
const summaryText = summary ?? "(No thinking trace available)"
|
||||||
|
return `Unstable background agent appears idle for ${idleSeconds}s.
|
||||||
|
|
||||||
|
Task ID: ${task.id}
|
||||||
|
Description: ${task.description}
|
||||||
|
Agent: ${task.agent}
|
||||||
|
Status: ${task.status}
|
||||||
|
Session ID: ${task.sessionID ?? "N/A"}
|
||||||
|
|
||||||
|
Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars):
|
||||||
|
${summaryText}
|
||||||
|
|
||||||
|
Suggested actions:
|
||||||
|
- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50
|
||||||
|
- background_cancel taskId="${task.id}"
|
||||||
|
|
||||||
|
This is a reminder only. No automatic action was taken.`
|
||||||
|
}
|
||||||
@ -1,11 +1,18 @@
|
|||||||
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import {
|
||||||
|
buildReminder,
|
||||||
|
extractMessages,
|
||||||
|
getMessageInfo,
|
||||||
|
getMessageParts,
|
||||||
|
isUnstableTask,
|
||||||
|
THINKING_SUMMARY_MAX_CHARS,
|
||||||
|
} from "./task-message-analyzer"
|
||||||
|
|
||||||
const HOOK_NAME = "unstable-agent-babysitter"
|
const HOOK_NAME = "unstable-agent-babysitter"
|
||||||
const DEFAULT_TIMEOUT_MS = 120000
|
const DEFAULT_TIMEOUT_MS = 120000
|
||||||
const COOLDOWN_MS = 5 * 60 * 1000
|
const COOLDOWN_MS = 5 * 60 * 1000
|
||||||
const THINKING_SUMMARY_MAX_CHARS = 500 as const
|
|
||||||
|
|
||||||
type BabysittingConfig = {
|
type BabysittingConfig = {
|
||||||
timeout_ms?: number
|
timeout_ms?: number
|
||||||
@ -43,72 +50,6 @@ type BabysitterOptions = {
|
|||||||
config?: BabysittingConfig
|
config?: BabysittingConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageInfo = {
|
|
||||||
role?: string
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
providerID?: string
|
|
||||||
modelID?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessagePart = {
|
|
||||||
type?: string
|
|
||||||
text?: string
|
|
||||||
thinking?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasData(value: unknown): value is { data?: unknown } {
|
|
||||||
return typeof value === "object" && value !== null && "data" in value
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageInfo(value: unknown): MessageInfo | undefined {
|
|
||||||
if (!isRecord(value)) return undefined
|
|
||||||
if (!isRecord(value.info)) return undefined
|
|
||||||
const info = value.info
|
|
||||||
const modelValue = isRecord(info.model)
|
|
||||||
? info.model
|
|
||||||
: undefined
|
|
||||||
const model = modelValue && typeof modelValue.providerID === "string" && typeof modelValue.modelID === "string"
|
|
||||||
? { providerID: modelValue.providerID, modelID: modelValue.modelID }
|
|
||||||
: undefined
|
|
||||||
return {
|
|
||||||
role: typeof info.role === "string" ? info.role : undefined,
|
|
||||||
agent: typeof info.agent === "string" ? info.agent : undefined,
|
|
||||||
model,
|
|
||||||
providerID: typeof info.providerID === "string" ? info.providerID : undefined,
|
|
||||||
modelID: typeof info.modelID === "string" ? info.modelID : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageParts(value: unknown): MessagePart[] {
|
|
||||||
if (!isRecord(value)) return []
|
|
||||||
if (!Array.isArray(value.parts)) return []
|
|
||||||
return value.parts.filter(isRecord).map((part) => ({
|
|
||||||
type: typeof part.type === "string" ? part.type : undefined,
|
|
||||||
text: typeof part.text === "string" ? part.text : undefined,
|
|
||||||
thinking: typeof part.thinking === "string" ? part.thinking : undefined,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractMessages(value: unknown): unknown[] {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
if (hasData(value) && Array.isArray(value.data)) {
|
|
||||||
return value.data
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUnstableTask(task: BackgroundTask): boolean {
|
|
||||||
if (task.isUnstableAgent === true) return true
|
|
||||||
const modelId = task.model?.modelID?.toLowerCase()
|
|
||||||
return modelId ? modelId.includes("gemini") || modelId.includes("minimax") : false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveMainSessionTarget(
|
async function resolveMainSessionTarget(
|
||||||
ctx: BabysitterContext,
|
ctx: BabysitterContext,
|
||||||
@ -169,27 +110,6 @@ async function getThinkingSummary(ctx: BabysitterContext, sessionID: string): Pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReminder(task: BackgroundTask, summary: string | null, idleMs: number): string {
|
|
||||||
const idleSeconds = Math.round(idleMs / 1000)
|
|
||||||
const summaryText = summary ?? "(No thinking trace available)"
|
|
||||||
return `Unstable background agent appears idle for ${idleSeconds}s.
|
|
||||||
|
|
||||||
Task ID: ${task.id}
|
|
||||||
Description: ${task.description}
|
|
||||||
Agent: ${task.agent}
|
|
||||||
Status: ${task.status}
|
|
||||||
Session ID: ${task.sessionID ?? "N/A"}
|
|
||||||
|
|
||||||
Thinking summary (first ${THINKING_SUMMARY_MAX_CHARS} chars):
|
|
||||||
${summaryText}
|
|
||||||
|
|
||||||
Suggested actions:
|
|
||||||
- background_output task_id="${task.id}" full_session=true include_thinking=true include_tool_results=true message_limit=50
|
|
||||||
- background_cancel taskId="${task.id}"
|
|
||||||
|
|
||||||
This is a reminder only. No automatic action was taken.`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
|
export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, options: BabysitterOptions) {
|
||||||
const reminderCooldowns = new Map<string, number>()
|
const reminderCooldowns = new Map<string, number>()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user