Skip to content
Merged
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
169 changes: 107 additions & 62 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,138 +10,183 @@ 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;
});

const resolvedConfig = createResolvedConfig(config);
const scheduler = createNotificationScheduler(resolvedConfig);
// Tracks sessions where assistant has responded since last user message
const activeSessions = new Set<string>();
// Tracks sessions interrupted explicitly by user command
const interruptedSessions = new Set<string>();

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();
},
};
};
3 changes: 2 additions & 1 deletion scripts/generate-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
2 changes: 1 addition & 1 deletion src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function createLogger(service: string): Logger {
level,
service,
message,
...extra,
extra,
}) + "\n";

const file = Bun.file(LOG_FILE);
Expand Down
Loading