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
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const createAppRouter = (
changes: createChangesRouter(),
filesystem: createFilesystemRouter(),
githubMetrics: createGitHubMetricsRouter(),
notifications: createNotificationsRouter(),
notifications: createNotificationsRouter(getWindow),
permissions: createPermissionsRouter(),
ports: createPortsRouter(),
resourceMetrics: createResourceMetricsRouter(),
Expand Down
114 changes: 113 additions & 1 deletion apps/desktop/src/lib/trpc/routers/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { observable } from "@trpc/server/observable";
import type {
BrowserWindow,
Notification as ElectronNotification,
} from "electron";
import { Notification } from "electron";
import {
type AgentLifecycleEvent,
type NotificationIds,
notificationsEmitter,
} from "main/lib/notifications/server";
import { NOTIFICATION_EVENTS } from "shared/constants";
import type { V2NotificationSourceFocusTarget } from "shared/notification-types";
import { z } from "zod";
import { publicProcedure, router } from "..";

type TerminalExitNotification = NotificationIds & {
Expand All @@ -19,13 +26,101 @@ type NotificationEvent =
data?: AgentLifecycleEvent;
}
| { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds }
| {
type: typeof NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE;
data?: V2NotificationSourceFocusTarget;
}
| {
type: typeof NOTIFICATION_EVENTS.TERMINAL_EXIT;
data?: TerminalExitNotification;
};

export const createNotificationsRouter = () => {
const v2NotificationSourceSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("terminal"), id: z.string().min(1) }),
z.object({ type: z.literal("chat"), id: z.string().min(1) }),
]);

const showNativeInputSchema = z.object({
title: z.string().min(1),
body: z.string(),
silent: z.boolean().default(true),
clickTarget: z
.object({
workspaceId: z.string().min(1),
source: v2NotificationSourceSchema,
})
.optional(),
});
type ShowNativeInput = z.infer<typeof showNativeInputSchema>;

const activeNativeNotifications = new Map<string, ElectronNotification>();
let nativeNotificationCounter = 0;

function focusWindow(getWindow: () => BrowserWindow | null): void {
const window = getWindow();
if (!window) return;
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}

function getNativeNotificationKey(input: ShowNativeInput): string {
const target = input.clickTarget;
if (!target) return `_native_${nativeNotificationCounter++}`;
return `${target.workspaceId}:${target.source.type}:${target.source.id}`;
}

function trackNativeNotification(
key: string,
notification: ElectronNotification,
): void {
const previous = activeNativeNotifications.get(key);
previous?.close();
activeNativeNotifications.set(key, notification);

const untrack = () => {
if (activeNativeNotifications.get(key) === notification) {
activeNativeNotifications.delete(key);
}
};
notification.on("click", untrack);
notification.on("close", untrack);
}

export const createNotificationsRouter = (
getWindow: () => BrowserWindow | null,
) => {
return router({
showNative: publicProcedure
.input(showNativeInputSchema)
.mutation(({ input }) => {
if (!Notification.isSupported()) {
return { success: false as const, reason: "unsupported" as const };
}

const notification = new Notification({
title: input.title,
body: input.body,
silent: input.silent,
});
const key = getNativeNotificationKey(input);
trackNativeNotification(key, notification);

notification.on("click", () => {
focusWindow(getWindow);
if (!input.clickTarget) return;
notificationsEmitter.emit(
NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE,
input.clickTarget,
);
});

notification.show();
return { success: true as const };
}),

subscribe: publicProcedure.subscription(() => {
return observable<NotificationEvent>((emit) => {
const onLifecycle = (data: AgentLifecycleEvent) => {
Expand All @@ -36,6 +131,15 @@ export const createNotificationsRouter = () => {
emit.next({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data });
};

const onFocusV2NotificationSource = (
data: V2NotificationSourceFocusTarget,
) => {
emit.next({
type: NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE,
data,
});
};

const onTerminalExit = (data: TerminalExitNotification) => {
emit.next({ type: NOTIFICATION_EVENTS.TERMINAL_EXIT, data });
};
Expand All @@ -45,6 +149,10 @@ export const createNotificationsRouter = () => {
onLifecycle,
);
notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab);
notificationsEmitter.on(
NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE,
onFocusV2NotificationSource,
);
notificationsEmitter.on(
NOTIFICATION_EVENTS.TERMINAL_EXIT,
onTerminalExit,
Expand All @@ -56,6 +164,10 @@ export const createNotificationsRouter = () => {
onLifecycle,
);
notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab);
notificationsEmitter.off(
NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE,
onFocusV2NotificationSource,
);
notificationsEmitter.off(
NOTIFICATION_EVENTS.TERMINAL_EXIT,
onTerminalExit,
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/lib/trpc/routers/ringtone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "main/lib/youtube-ringtone";
import {
CUSTOM_RINGTONE_ID,
DEFAULT_RINGTONE_ID,
getRingtoneFilename,
isBuiltInRingtoneId,
} from "shared/ringtones";
Expand Down Expand Up @@ -114,6 +115,15 @@ function getRingtoneSoundPath(ringtoneId: string): string | null {
return getSoundPath(filename);
}

function getNotificationRingtoneSoundPath(ringtoneId: string): string | null {
const soundPath = getRingtoneSoundPath(ringtoneId);
if (soundPath) return soundPath;

if (ringtoneId !== CUSTOM_RINGTONE_ID) return null;
const fallbackFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID);
return fallbackFilename ? getSoundPath(fallbackFilename) : null;
}

/**
* Ringtone router for audio preview and playback operations
*/
Expand All @@ -139,6 +149,27 @@ export const createRingtoneRouter = (getWindow: () => BrowserWindow | null) => {
return { success: true as const };
}),

/**
* Play the selected notification ringtone from main when the renderer cannot
* access the backing asset directly, namely imported custom audio files.
*/
playNotification: publicProcedure
.input(
z.object({
ringtoneId: z.string(),
volume: z.number().min(0).max(100).optional(),
}),
)
.mutation(({ input }) => {
const soundPath = getNotificationRingtoneSoundPath(input.ringtoneId);
if (!soundPath) {
return { success: true as const };
}

playSoundFile(soundPath, input.volume ?? 100);
return { success: true as const };
}),

/**
* Stop the currently playing ringtone preview
*/
Expand Down
39 changes: 37 additions & 2 deletions apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import path from "node:path";

describe("getNotifyScriptContent", () => {
it("prefers Mastra resourceId over internal session_id", () => {
it("keeps v1 fallback session ids out of the v2 host-service payload", () => {
const script = readFileSync(
path.join(import.meta.dir, "templates", "notify-hook.template.sh"),
"utf-8",
Expand All @@ -13,12 +13,47 @@ describe("getNotifyScriptContent", () => {
expect(script).toContain(
"SESSION_ID=" + "\u0024{RESOURCE_ID:-$HOOK_SESSION_ID}",
);
expect(script).toContain(
'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"',
);
expect(script).toContain('--data-urlencode "resourceId=$RESOURCE_ID"');
expect(script).toContain(
'--data-urlencode "hookSessionId=$HOOK_SESSION_ID"',
);
expect(script).toContain(
"event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID",
"event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID",
);
});

it("gives the v2 host-service hook enough time to avoid false fallback", () => {
const script = readFileSync(
path.join(import.meta.dir, "templates", "notify-hook.template.sh"),
"utf-8",
);

expect(script).toContain(
'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \\\n --connect-timeout 2 --max-time 5',
);
});

it("keeps the legacy v1 fallback path when no host-service hook URL exists", () => {
const script = readFileSync(
path.join(import.meta.dir, "templates", "notify-hook.template.sh"),
"utf-8",
);

expect(script).toContain('if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then');
expect(script).toContain(
'[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0',
);
expect(script).toContain(
'curl -sG "http://127.0.0.1:' +
"$" +
"{SUPERSET_PORT:-{{DEFAULT_PORT}}}" +
'/hook/complete"',
);
expect(script).toContain('--data-urlencode "paneId=$SUPERSET_PANE_ID"');
expect(script).toContain('--data-urlencode "tabId=$SUPERSET_TAB_ID"');
expect(script).toContain('--data-urlencode "sessionId=$SESSION_ID"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ $ResourceId = Get-JsonStringValue $Json 'resourceId'
if (-not $ResourceId) { $ResourceId = Get-JsonStringValue $Json 'resource_id' }
$SessionId = if ($ResourceId) { $ResourceId } else { $HookSessionId }

if (-not $env:SUPERSET_TAB_ID -and -not $SessionId) { exit 0 }
# v2 terminal hooks identify the runtime by SUPERSET_TERMINAL_ID.
# v1 fallback still uses tab/session fields, so guard accordingly.
if ($env:SUPERSET_HOST_AGENT_HOOK_URL) {
if (-not $env:SUPERSET_TERMINAL_ID) { exit 0 }
} else {
if (-not $env:SUPERSET_TAB_ID -and -not $SessionId) { exit 0 }
}

$EventType = Get-JsonStringValue $Json 'hook_event_name'
if (-not $EventType) {
Expand Down Expand Up @@ -52,7 +58,27 @@ if ($env:SUPERSET_DEBUG_HOOKS) {
}

if ($DebugEnabled) {
[Console]::Error.WriteLine("[notify-hook] event=$EventType sessionId=$SessionId hookSessionId=$HookSessionId resourceId=$ResourceId paneId=$env:SUPERSET_PANE_ID tabId=$env:SUPERSET_TAB_ID workspaceId=$env:SUPERSET_WORKSPACE_ID wrapperPid=$env:SUPERSET_WRAPPER_PID")
[Console]::Error.WriteLine("[notify-hook] event=$EventType terminalId=$env:SUPERSET_TERMINAL_ID sessionId=$SessionId hookSessionId=$HookSessionId resourceId=$ResourceId paneId=$env:SUPERSET_PANE_ID tabId=$env:SUPERSET_TAB_ID workspaceId=$env:SUPERSET_WORKSPACE_ID wrapperPid=$env:SUPERSET_WRAPPER_PID")
}

# v2: host-service tRPC endpoint. The renderer subscribes over the event bus
# and plays the ringtone. Preferred when the URL is provided by host-service's
# terminal env. Falls through to v1 on non-2xx or network error.
if ($env:SUPERSET_HOST_AGENT_HOOK_URL) {
$V2Payload = "{`"json`":{`"terminalId`":`"$($env:SUPERSET_TERMINAL_ID)`",`"eventType`":`"$EventType`"}}"
try {
$V2Response = Invoke-WebRequest -Uri $env:SUPERSET_HOST_AGENT_HOOK_URL `
-Method Post -UseBasicParsing -TimeoutSec 5 `
-ContentType 'application/json' -Body $V2Payload -ErrorAction Stop
if ($DebugEnabled) {
[Console]::Error.WriteLine("[notify-hook] host-service dispatched status=$($V2Response.StatusCode)")
}
if ($V2Response.StatusCode -ge 200 -and $V2Response.StatusCode -lt 300) { exit 0 }
} catch {
if ($DebugEnabled) {
[Console]::Error.WriteLine("[notify-hook] host-service dispatch failed, falling back to v1: $_")
}
}
}

$Port = if ($env:SUPERSET_PORT) { $env:SUPERSET_PORT } else { '{{DEFAULT_PORT}}' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,14 @@ if [ -z "$RESOURCE_ID" ]; then
fi
SESSION_ID=${RESOURCE_ID:-$HOOK_SESSION_ID}

# Skip if this isn't a Superset terminal hook and no Mastra session context exists
[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0
# v2 terminal hooks identify the runtime by terminalId. The v1 fallback still
# uses pane/tab/session fields, so keep its legacy guard when no host-service
# hook URL is available.
if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then
[ -z "$SUPERSET_TERMINAL_ID" ] && exit 0
else
[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0
fi

# Extract event type - Claude uses "hook_event_name", Codex uses "type"
# Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value"
Expand Down Expand Up @@ -70,9 +76,40 @@ elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; the
fi

if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID wrapperPid=$SUPERSET_WRAPPER_PID" >&2
echo "[notify-hook] event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2
fi

# Escape backslashes and double quotes for safe JSON embedding.
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
}

# v2: host-service tRPC endpoint. The renderer subscribes over the event
# bus and plays the ringtone. Preferred when the URL is provided by
# host-service's terminal env. Endpoint is unauthenticated — it only
# broadcasts chimes, no auth header needed. Always captures the status
# so we can fall back to v1 when host-service is unreachable or the
# mutation returns non-2xx (restarts, crashes, transient errors).
if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then
PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}"

STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \
--connect-timeout 2 --max-time 5 \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
-o /dev/null -w "%{http_code}" 2>/dev/null)

if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2
fi

case "$STATUS_CODE" in
2*) exit 0 ;;
esac
fi

# v1 fallback: electron localhost server. Used by v1 terminals and when
# host-service is unreachable from the agent's shell.
# Timeouts prevent blocking agent completion if notification server is unresponsive
if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then
STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \
Expand Down
Loading
Loading