import { Plugin } from "@opencode-ai/plugin/tui" import type { AttentionSoundName } from "@opencode-ai/plugin/tui/context" import type { OpenCodeEvent } from "@opencode-ai/client" type SessionError = Extract["data"]["error"] function notify( context: Plugin.Context, sessionID: string | undefined, message: string, sound: AttentionSoundName, title?: string, ) { const session = sessionID ? context.data.session.get(sessionID) : undefined const isSubagent = session?.parentID !== undefined void context.attention.notify({ title: title ?? session?.title, message, notification: isSubagent ? false : { when: "blurred" }, sound: { name: sound, when: "always" }, }) } function sessionErrorMessage(error: SessionError) { if (error?.name === "MessageAbortedError") return "Session aborted" const data = error?.data if (data && typeof data === "object" && "message" in data && data.message === "SSE read timed out") { return "Model stopped responding" } return "Session error" } export default Plugin.define({ id: "opencode.notifications", setup(context) { const errored = new Set() const terminal = new Set() const forms = new Set() const questions = new Set() const permissions = new Set() const started = (sessionID: string) => { errored.delete(sessionID) terminal.delete(sessionID) } const ended = (sessionID: string) => { if (terminal.has(sessionID)) return terminal.add(sessionID) if (errored.has(sessionID)) { errored.delete(sessionID) return } const session = context.data.session.get(sessionID) notify(context, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") } const dispose = [ context.data.on("form.created", (event) => { if (forms.has(event.data.form.id)) return forms.add(event.data.form.id) notify(context, event.data.form.sessionID, "Input needs response", "question", event.data.form.title) }), context.data.on("form.replied", (event) => forms.delete(event.data.id)), context.data.on("form.cancelled", (event) => forms.delete(event.data.id)), context.data.on("question.asked", (event) => { if (questions.has(event.data.id)) return questions.add(event.data.id) notify(context, event.data.sessionID, "Question needs input", "question") }), context.data.on("question.replied", (event) => questions.delete(event.data.requestID)), context.data.on("question.rejected", (event) => questions.delete(event.data.requestID)), context.data.on("permission.asked", (event) => { if (permissions.has(event.data.id)) return permissions.add(event.data.id) notify(context, event.data.sessionID, "Permission needs input", "permission") }), context.data.on("permission.replied", (event) => permissions.delete(event.data.requestID)), context.data.on("session.execution.started", (event) => started(event.data.sessionID)), context.data.on("session.execution.succeeded", (event) => ended(event.data.sessionID)), context.data.on("session.execution.interrupted", (event) => ended(event.data.sessionID)), context.data.on("session.execution.failed", (event) => { const sessionID = event.data.sessionID if (errored.has(sessionID)) { ended(sessionID) return } errored.add(sessionID) notify(context, sessionID, event.data.error.message, "error") ended(sessionID) }), context.data.on("session.error", (event) => { const sessionID = event.data.sessionID if (!sessionID) return if (context.data.session.status(sessionID) !== "running") return if (errored.has(sessionID)) return errored.add(sessionID) notify(context, sessionID, sessionErrorMessage(event.data.error), "error") }), ] return () => dispose.reverse().forEach((cleanup) => cleanup()) }, })