Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f6aed52
save
Kitenite Apr 23, 2026
301d97d
Merge remote-tracking branch 'origin' into in-v2-we-want-to-play-noti…
Kitenite Apr 23, 2026
7767b72
feat(desktop): wire v2 agent-hook sidebar status + terminalId payload
Kitenite Apr 23, 2026
6acb4a3
chore(desktop): hoist v2 agent-hook listener + drop debug logs
Kitenite Apr 23, 2026
e6dab18
fix(desktop): address PR review on v2 agent-hook sidebar
Kitenite Apr 23, 2026
87d0796
fix(host-service): drop auth on notifications.hook, stop leaking PSK
Kitenite Apr 24, 2026
103c00a
fix(desktop): v1 fallback on v2 non-2xx + safer autoplay priming
Kitenite Apr 24, 2026
3206979
docs(plans): rewrite v2 notif hooks doc as shipped retrospective
Kitenite Apr 24, 2026
fbd1a0c
wip(desktop): partial-merge resolutions for v2 notification hooks
Kitenite Apr 24, 2026
26800fd
Merge remote-tracking branch 'origin/main' into in-v2-we-want-to-play…
Kitenite Apr 24, 2026
c6696c1
fix(desktop): simplify v2 notification hook identity
Kitenite Apr 24, 2026
140fdf3
refactor(desktop): normalize v2 notification state
Kitenite Apr 25, 2026
e3fcd2d
feat(desktop): show v2 notification status on tabs and panes
Kitenite Apr 25, 2026
08a195f
refactor(desktop): type v2 notification sources
Kitenite Apr 25, 2026
7a51f42
Merge remote-tracking branch 'origin' into in-v2-we-want-to-play-noti…
Kitenite Apr 25, 2026
f8337fa
feat(desktop): show v2 native notifications from main
Kitenite Apr 25, 2026
56812fa
fix(desktop): polish v2 notification focus and audio
Kitenite Apr 25, 2026
30a23d9
Merge remote-tracking branch 'origin' into in-v2-we-want-to-play-noti…
Kitenite Apr 25, 2026
ebb8ad8
[codex] Align ringtone playback with VS Code pattern
Kitenite Apr 25, 2026
9942732
[codex] Move v2 notification lifecycle logic
Kitenite Apr 25, 2026
2fa1079
[codex] Refresh notification docs and v1 fallback test
Kitenite Apr 25, 2026
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 @@ -43,7 +43,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
terminal: createTerminalRouter(),
changes: createChangesRouter(),
filesystem: createFilesystemRouter(),
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 @@ -11,6 +11,7 @@ import { playSoundFile } from "main/lib/play-sound";
import { getSoundPath } from "main/lib/sound-paths";
import {
CUSTOM_RINGTONE_ID,
DEFAULT_RINGTONE_ID,
getRingtoneFilename,
isBuiltInRingtoneId,
} from "shared/ringtones";
Expand Down Expand Up @@ -92,6 +93,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 @@ -117,6 +127,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 @@ -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 @@ -68,9 +74,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" >&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
Comment thread
Kitenite marked this conversation as resolved.

# 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
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
type AgentLifecyclePayload,
type GitChangedPayload,
getEventBus,
type TerminalLifecyclePayload,
} from "@superset/workspace-client";
import type { FsWatchEvent } from "@superset/workspace-fs/client";
import { useEffect, useEffectEvent } from "react";
Expand All @@ -24,11 +26,25 @@ export function useWorkspaceEvent(
enabled?: boolean,
): void;
export function useWorkspaceEvent(
type: "git:changed" | "fs:events",
type: "agent:lifecycle",
workspaceId: string,
callback: (payload: AgentLifecyclePayload) => void,
enabled?: boolean,
): void;
export function useWorkspaceEvent(
type: "terminal:lifecycle",
workspaceId: string,
callback: (payload: TerminalLifecyclePayload) => void,
enabled?: boolean,
): void;
export function useWorkspaceEvent(
type: "git:changed" | "fs:events" | "agent:lifecycle" | "terminal:lifecycle",
workspaceId: string,
callback:
| ((event: FsWatchEvent) => void)
| ((payload: GitChangedPayload) => void),
| ((payload: GitChangedPayload) => void)
| ((payload: AgentLifecyclePayload) => void)
| ((payload: TerminalLifecyclePayload) => void),
enabled = true,
): void {
const hostUrl = useWorkspaceHostUrl(workspaceId);
Expand All @@ -52,6 +68,24 @@ export function useWorkspaceEvent(
},
);
cleanups.push(removeListener, () => bus.unwatchFs(workspaceId));
} else if (type === "agent:lifecycle") {
const removeListener = bus.on(
"agent:lifecycle",
workspaceId,
(_wid, payload) => {
(handler as (payload: AgentLifecyclePayload) => void)(payload);
},
);
cleanups.push(removeListener);
} else if (type === "terminal:lifecycle") {
const removeListener = bus.on(
"terminal:lifecycle",
workspaceId,
(_wid, payload) => {
(handler as (payload: TerminalLifecyclePayload) => void)(payload);
},
);
cleanups.push(removeListener);
} else {
const removeListener = bus.on(
"git:changed",
Expand Down
Loading
Loading