diff --git a/index.ts b/index.ts index b66783b..e1858c4 100644 --- a/index.ts +++ b/index.ts @@ -10,11 +10,7 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { error instanceof ConfigError ? `Notification plugin config error: ${error.message}` : `Notification plugin failed to load: ${error instanceof Error ? error.message : String(error)}`; - - await client.tui.showToast({ - body: { message, variant: "error" }, - }); - + await client.tui.showToast({ body: { message, variant: "error" } }); throw error; }); @@ -22,126 +18,175 @@ export const SimpleNotificationPlugin: Plugin = async ({ client }) => { const scheduler = createNotificationScheduler(resolvedConfig); // Tracks sessions where assistant has responded since last user message const activeSessions = new Set(); + // Tracks sessions interrupted explicitly by user command + const interruptedSessions = new Set(); const cancelForSession = (sessionId: string) => { scheduler.cancelForSession(sessionId); activeSessions.delete(sessionId); }; + const markSessionInterrupted = (sessionId: string) => { + interruptedSessions.add(sessionId); + cancelForSession(sessionId); + }; + + const isDeniedReply = (response: unknown) => { + const normalized = String(response ?? "") + .trim() + .toLowerCase(); + + return ( + normalized.includes("deny") || + normalized.includes("reject") || + normalized.includes("interrupt") || + normalized.includes("cancel") || + normalized.includes("abort") || + normalized === "esc" + ); + }; + return { event: async ({ event }) => { switch (event.type) { case "session.idle": { const sessionId = event.properties.sessionID; - if (activeSessions.has(sessionId)) { - const title = await client.session - .get({ path: { id: sessionId } }) - .then((details) => details.data?.title) - .catch(() => undefined); - scheduler.schedule(sessionId, "Response ready", title ?? sessionId, "session.idle"); + + if (interruptedSessions.has(sessionId)) { + interruptedSessions.delete(sessionId); + activeSessions.delete(sessionId); + return; } + + if (!activeSessions.has(sessionId)) return; + + const session = await client.session.get({ path: { id: sessionId } }).catch(() => null); + const title = session?.data?.title ?? sessionId; + + scheduler.schedule(sessionId, "Response ready", title, "session.idle"); activeSessions.delete(sessionId); - break; + return; } case "session.error": { const sessionId = event.properties.sessionID; - const message = sessionId - ? await client.session - .get({ path: { id: sessionId } }) - .then((details) => details.data?.title) - .catch(() => undefined) - : (event.properties.error?.data.message as string); - if (sessionId) { - scheduler.schedule(sessionId, "Session error", message ?? sessionId, "session.error"); + if (!sessionId) return; + + const isAborted = event.properties.error?.name === "MessageAbortedError"; + + if (isAborted) { + markSessionInterrupted(sessionId); + return; + } + + const session = await client.session.get({ path: { id: sessionId } }).catch(() => null); + const title = session?.data?.title ?? sessionId; + scheduler.schedule(sessionId, "Session error", title, "session.error"); + return; + } + + case "command.executed": { + const sessionId = event.properties.sessionID; + const commandName = event.properties.name; + if (commandName === "session.interrupt") { + markSessionInterrupted(sessionId); } - break; + return; } // @ts-ignore: SDK v1 doesn't have permission types yet case "permission.asked": { - const evt = event as { properties: { sessionID: string } }; - const sessionId = evt.properties.sessionID; - const session = await client.session - .get({ path: { id: sessionId } }) - .then((details) => ({ - title: details.data?.title, - directory: details.data?.directory, - })) - .catch(() => undefined); - const projectName = path.basename(session?.directory ?? ""); + const sessionId = (event as { properties: { sessionID: string } }).properties.sessionID; + const session = await client.session.get({ path: { id: sessionId } }).catch(() => null); + const projectName = path.basename(session?.data?.directory ?? ""); scheduler.schedule( sessionId, "Permission Asked", - `${session?.title} in ${projectName} needs permission`, + `${session?.data?.title} in ${projectName} needs permission`, "permission.asked", ); - break; + return; } // @ts-ignore: SDK v1 doesn't have question types yet case "question.asked": { - const evt = event as { properties: { sessionID: string } }; - const sessionId = evt.properties.sessionID; - const session = await client.session - .get({ path: { id: sessionId } }) - .then((details) => ({ - title: details.data?.title, - directory: details.data?.directory, - })) - .catch(() => undefined); - const projectName = path.basename(session?.directory ?? ""); + const sessionId = (event as { properties: { sessionID: string } }).properties.sessionID; + const session = await client.session.get({ path: { id: sessionId } }).catch(() => null); + const projectName = path.basename(session?.data?.directory ?? ""); scheduler.schedule( sessionId, "Question", - `${session?.title} in ${projectName} has a question`, + `${session?.data?.title} in ${projectName} has a question`, "question.asked", ); - break; + return; } // @ts-ignore: SDK v1 doesn't have permission types yet case "permission.replied": // @ts-ignore: SDK v1 doesn't have question types yet case "question.replied": { - const evt = event as { properties: { sessionID: string } }; - const sessionId = evt.properties.sessionID; - cancelForSession(sessionId); - break; + const sessionId = (event as { properties: { sessionID: string } }).properties.sessionID; + const response = (event as { properties: { response?: unknown } }).properties.response; + if (isDeniedReply(response)) { + markSessionInterrupted(sessionId); + } else { + cancelForSession(sessionId); + } + return; } case "message.updated": { const info = event.properties.info; - if (info.role === "user") { - const infoAny = info; - const isAutomaticMessage = infoAny.agent || infoAny.model; - if (!isAutomaticMessage) { - cancelForSession(info.sessionID); + + if (info.role === "assistant") { + if (info.error?.name === "MessageAbortedError") { + markSessionInterrupted(info.sessionID); + return; } - } else if (info.role === "assistant") { activeSessions.add(info.sessionID); + } else if (info.role === "user") { + if (!info.agent && !info.model) { + interruptedSessions.delete(info.sessionID); + cancelForSession(info.sessionID); + } } - break; + return; + } + + case "message.part.updated": { + const part = event.properties.part as { + sessionID: string; + type: string; + state?: { status?: string; error?: string }; + }; + const isDismissed = + part.state?.status === "error" && + part.state?.error?.toLowerCase().includes("dismissed"); + + if (isDismissed && ["tool", "question", "permission"].includes(part.type)) { + markSessionInterrupted(part.sessionID); + } + return; } case "session.status": { - const status = event.properties.status; - const sessionId = event.properties.sessionID; - if (status.type === "busy") { - scheduler.cancelForSession(sessionId); + if (event.properties.status.type === "busy") { + scheduler.cancelForSession(event.properties.sessionID); } - break; + return; } case "tui.prompt.append": case "tui.command.execute": - break; + return; } }, destroy: () => { scheduler.cancelAll(); activeSessions.clear(); + interruptedSessions.clear(); }, }; }; diff --git a/scripts/generate-schema.ts b/scripts/generate-schema.ts index 4dc2820..c00aaf4 100644 --- a/scripts/generate-schema.ts +++ b/scripts/generate-schema.ts @@ -27,6 +27,7 @@ const schemaWithId = { }; const outputPath = `${import.meta.dir}/../schema/${CONFIG_FILE_NAME}`; -await Bun.write(outputPath, JSON.stringify(schemaWithId, null, 2)); +const jsonString = `${JSON.stringify(schemaWithId, null, "\t")}\n`; +await Bun.write(outputPath, jsonString); console.log(`Generated JSON schema: schema/${CONFIG_FILE_NAME} (version ${version})`); diff --git a/src/logger.ts b/src/logger.ts index 9f27cdb..cb78fe0 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -20,7 +20,7 @@ export function createLogger(service: string): Logger { level, service, message, - ...extra, + extra, }) + "\n"; const file = Bun.file(LOG_FILE);