diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index eef4bd20cf9..43c994c2d36 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md \ No newline at end of file +@AGENTS.md diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b060cb8f59b..b5536cb81a2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -36,6 +36,7 @@ "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "@trpc/server": "^11.7.1", + "@types/express": "^5.0.5", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "^0.11.0", @@ -46,6 +47,7 @@ "electron-router-dom": "^2.1.0", "electron-store": "^11.0.2", "execa": "^9.6.0", + "express": "^5.1.0", "fast-glob": "^3.3.3", "framer-motion": "^12.23.24", "http-proxy": "^1.18.1", diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 6f7b166dbff..9d9789cdca5 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; +import { createNotificationsRouter } from "./notifications"; import { createProjectsRouter } from "./projects"; import { createTerminalRouter } from "./terminal"; import { createWindowRouter } from "./window"; @@ -15,6 +16,7 @@ export const createAppRouter = (window: BrowserWindow) => { projects: createProjectsRouter(window), workspaces: createWorkspacesRouter(), terminal: createTerminalRouter(), + notifications: createNotificationsRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts new file mode 100644 index 00000000000..0498013729b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -0,0 +1,37 @@ +import { observable } from "@trpc/server/observable"; +import { + notificationsEmitter, + type AgentCompleteEvent, +} from "main/lib/notifications/server"; +import { publicProcedure, router } from ".."; + +type NotificationEvent = + | { type: "agent-complete"; data: AgentCompleteEvent } + | { type: "focus-tab"; data: { tabId: string; workspaceId: string } }; + +export const createNotificationsRouter = () => { + return router({ + /** + * Subscribe to notification events (completions and focus requests). + */ + subscribe: publicProcedure.subscription(() => { + return observable((emit) => { + const onComplete = (event: AgentCompleteEvent) => { + emit.next({ type: "agent-complete", data: event }); + }; + + const onFocusTab = (data: { tabId: string; workspaceId: string }) => { + emit.next({ type: "focus-tab", data }); + }; + + notificationsEmitter.on("agent-complete", onComplete); + notificationsEmitter.on("focus-tab", onFocusTab); + + return () => { + notificationsEmitter.off("agent-complete", onComplete); + notificationsEmitter.off("focus-tab", onFocusTab); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 85706511c0b..1148bb4d640 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -7,6 +7,17 @@ import { publicProcedure, router } from "../.."; /** * Terminal router using TerminalManager with node-pty * Sessions are keyed by tabId and linked to workspaces for cwd resolution + * + * IMPORTANT: When creating terminals, ensure these env vars are passed: + * - PATH: Prepend ~/.superset/bin (use getSupersetBinDir() from agent-setup) + * - SUPERSET_TAB_ID: The tab's ID + * - SUPERSET_TAB_TITLE: The tab's display title + * - SUPERSET_WORKSPACE_NAME: The workspace name + * - SUPERSET_PORT: The hooks server port (use getHooksServerPort()) + * + * PATH prepending ensures our wrapper scripts (~/.superset/bin/claude, codex) + * are used instead of system binaries. These wrappers inject hook settings + * that notify the app when agents complete their tasks. */ export const createTerminalRouter = () => { return router({ @@ -15,16 +26,18 @@ export const createTerminalRouter = () => { z.object({ tabId: z.string(), workspaceId: z.string(), + tabTitle: z.string(), cols: z.number().optional(), rows: z.number().optional(), }), ) .mutation(async ({ input }) => { - const { tabId, workspaceId, cols, rows } = input; + const { tabId, workspaceId, tabTitle, cols, rows } = input; - // Get workspace to determine cwd from worktree path + // Get workspace to determine cwd and workspace name const workspace = db.data.workspaces.find((w) => w.id === workspaceId); let cwd: string | undefined; + const workspaceName = workspace?.name || "Workspace"; if (workspace) { const worktree = db.data.worktrees.find( @@ -38,6 +51,8 @@ export const createTerminalRouter = () => { const result = await terminalManager.createOrAttach({ tabId, workspaceId, + tabTitle, + workspaceName, cwd, cols, rows, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index bad1bf5f694..a9bc14e6a6d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,6 +4,7 @@ import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { initDb } from "./lib/db"; import { registerStorageHandlers } from "./lib/storage-ipcs"; import { terminalManager } from "./lib/terminal-manager"; +import { setupAgentHooks } from "./lib/agent-setup"; import { MainWindow } from "./windows/main"; // Protocol scheme for deep linking @@ -33,9 +34,15 @@ registerStorageHandlers(); (async () => { await app.whenReady(); - // Initialize database await initDb(); + try { + setupAgentHooks(); + } catch (error) { + console.error("[main] Failed to set up agent hooks:", error); + // App can continue without agent hooks, but log the failure + } + await makeAppSetup(() => MainWindow()); // Clean up all terminals when app is quitting diff --git a/apps/desktop/src/main/lib/agent-setup.ts b/apps/desktop/src/main/lib/agent-setup.ts new file mode 100644 index 00000000000..1ae103b92a3 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup.ts @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; +import { NOTIFICATIONS_PORT } from "shared/constants"; + +const SUPERSET_DIR = path.join(os.homedir(), ".superset"); +const BIN_DIR = path.join(SUPERSET_DIR, "bin"); +const HOOKS_DIR = path.join(SUPERSET_DIR, "hooks"); + +/** + * Finds the real path of a binary, skipping our wrapper scripts + */ +function findRealBinary(name: string): string | null { + try { + // Get all paths, filter out our bin dir + const result = execSync(`which -a ${name} 2>/dev/null || true`, { + encoding: "utf-8", + }); + const paths = result + .trim() + .split("\n") + .filter((p) => p && !p.startsWith(BIN_DIR)); + return paths[0] || null; + } catch { + return null; + } +} + +/** + * Creates the notify.sh script + */ +function createNotifyScript(): void { + const notifyPath = path.join(HOOKS_DIR, "notify.sh"); + const script = `#!/bin/bash +# Superset agent notification hook +# Called by CLI agents (Claude Code, Codex, etc.) when they complete + +# Only run if inside a Superset terminal +[ -z "$SUPERSET_TAB_ID" ] && exit 0 + +curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${NOTIFICATIONS_PORT}}/hook/complete" \\ + --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ + --data-urlencode "tabTitle=$SUPERSET_TAB_TITLE" \\ + --data-urlencode "workspaceName=$SUPERSET_WORKSPACE_NAME" \\ + --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \\ + > /dev/null 2>&1 +`; + fs.writeFileSync(notifyPath, script, { mode: 0o755 }); +} + +/** + * Creates wrapper script for Claude Code + */ +function createClaudeWrapper(): void { + const wrapperPath = path.join(BIN_DIR, "claude"); + const realClaude = findRealBinary("claude"); + + if (!realClaude) { + console.log("[agent-setup] Claude not found, skipping wrapper"); + return; + } + + const script = `#!/bin/bash +# Superset wrapper for Claude Code +# Injects notification hook settings + +SUPERSET_CLAUDE_SETTINGS='{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"~/.superset/hooks/notify.sh"}]}]}}' + +exec "${realClaude}" --settings "$SUPERSET_CLAUDE_SETTINGS" "$@" +`; + fs.writeFileSync(wrapperPath, script, { mode: 0o755 }); + console.log(`[agent-setup] Created Claude wrapper -> ${realClaude}`); +} + +/** + * Creates wrapper script for Codex + */ +function createCodexWrapper(): void { + const wrapperPath = path.join(BIN_DIR, "codex"); + const realCodex = findRealBinary("codex"); + + if (!realCodex) { + console.log("[agent-setup] Codex not found, skipping wrapper"); + return; + } + + const notifyPath = path.join(HOOKS_DIR, "notify.sh"); + const script = `#!/bin/bash +# Superset wrapper for Codex +# Injects notification hook settings + +exec "${realCodex}" -c 'notify=["bash","${notifyPath}"]' "$@" +`; + fs.writeFileSync(wrapperPath, script, { mode: 0o755 }); + console.log(`[agent-setup] Created Codex wrapper -> ${realCodex}`); +} + +/** + * Sets up the ~/.superset directory structure and agent wrappers + * Called on app startup + */ +export function setupAgentHooks(): void { + console.log("[agent-setup] Initializing agent hooks..."); + + // Create directories + fs.mkdirSync(BIN_DIR, { recursive: true }); + fs.mkdirSync(HOOKS_DIR, { recursive: true }); + + // Create scripts + createNotifyScript(); + createClaudeWrapper(); + createCodexWrapper(); + + console.log("[agent-setup] Agent hooks initialized"); +} + +/** + * Returns the PATH with our bin directory prepended + */ +export function getSupersetPath(): string { + return `${BIN_DIR}:${process.env.PATH || ""}`; +} + +/** + * Returns the bin directory path + */ +export function getSupersetBinDir(): string { + return BIN_DIR; +} diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts new file mode 100644 index 00000000000..3551e871166 --- /dev/null +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "node:events"; +import express from "express"; +import { NOTIFICATIONS_PORT } from "shared/constants"; + +export interface AgentCompleteEvent { + tabId: string; + tabTitle: string; + workspaceName: string; + workspaceId: string; +} + +export const notificationsEmitter = new EventEmitter(); + +const app = express(); + +// CORS +app.use((req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + if (req.method === "OPTIONS") { + return res.status(200).end(); + } + next(); +}); + +// Agent completion hook +app.get("/hook/complete", (req, res) => { + const { tabId, tabTitle, workspaceName, workspaceId } = req.query; + + if (!tabId || typeof tabId !== "string") { + return res.status(400).json({ error: "Missing tabId parameter" }); + } + + const event: AgentCompleteEvent = { + tabId, + tabTitle: (tabTitle as string) || "Terminal", + workspaceName: (workspaceName as string) || "Workspace", + workspaceId: (workspaceId as string) || "", + }; + + notificationsEmitter.emit("agent-complete", event); + + res.json({ success: true, tabId }); +}); + +// Health check +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +// 404 +app.use((req, res) => { + res.status(404).json({ error: "Not found" }); +}); + +export const notificationsApp = app; +export { NOTIFICATIONS_PORT }; diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index b47b149626a..c0870201283 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -1,6 +1,8 @@ import { EventEmitter } from "node:events"; import os from "node:os"; import * as pty from "node-pty"; +import { getSupersetPath } from "./agent-setup"; +import { NOTIFICATIONS_PORT } from "shared/constants"; import { HistoryReader, HistoryWriter } from "./terminal-history"; interface TerminalSession { @@ -40,6 +42,8 @@ export class TerminalManager extends EventEmitter { async createOrAttach(params: { tabId: string; workspaceId: string; + tabTitle: string; + workspaceName: string; cwd?: string; cols?: number; rows?: number; @@ -48,7 +52,9 @@ export class TerminalManager extends EventEmitter { scrollback: string[]; wasRecovered: boolean; }> { - const { tabId, workspaceId, cwd, cols, rows } = params; + const { tabId, workspaceId, tabTitle, workspaceName, cwd, cols, rows } = + params; + const existing = this.sessions.get(tabId); if (existing?.isAlive) { @@ -68,6 +74,17 @@ export class TerminalManager extends EventEmitter { const terminalCols = cols || this.DEFAULT_COLS; const terminalRows = rows || this.DEFAULT_ROWS; + // Build env with agent hook variables + const baseEnv = this.sanitizeEnv(process.env) || {}; + const env = { + ...baseEnv, + PATH: getSupersetPath(), + SUPERSET_TAB_ID: tabId, + SUPERSET_TAB_TITLE: tabTitle, + SUPERSET_WORKSPACE_NAME: workspaceName, + SUPERSET_WORKSPACE_ID: workspaceId, + SUPERSET_PORT: String(NOTIFICATIONS_PORT), + }; const historyReader = new HistoryReader(workspaceId, tabId); const recovery = await historyReader.getLatestSession(); @@ -76,7 +93,7 @@ export class TerminalManager extends EventEmitter { cols: terminalCols, rows: terminalRows, cwd: workingDir, - env: this.sanitizeEnv(process.env), + env, }); const historyWriter = new HistoryWriter( diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 8fd89c14080..7776c276f9d 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,10 +1,16 @@ import { join } from "node:path"; -import { screen } from "electron"; +import { Notification, screen } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; import { createIPCHandler } from "trpc-electron/main"; import { displayName } from "~/package.json"; import { createApplicationMenu } from "../lib/menu"; +import { + notificationsApp, + notificationsEmitter, + NOTIFICATIONS_PORT, + type AgentCompleteEvent, +} from "../lib/notifications/server"; export async function MainWindow() { const { width, height } = screen.getPrimaryDisplay().workAreaSize; @@ -38,11 +44,42 @@ export async function MainWindow() { windows: [window], }); + // Start notifications HTTP server + const server = notificationsApp.listen(NOTIFICATIONS_PORT, "127.0.0.1", () => { + console.log(`[notifications] Listening on http://127.0.0.1:${NOTIFICATIONS_PORT}`); + }); + + // Handle agent completion notifications + notificationsEmitter.on("agent-complete", (event: AgentCompleteEvent) => { + if (Notification.isSupported()) { + const notification = new Notification({ + title: `Agent Complete — ${event.workspaceName}`, + body: `"${event.tabTitle}" has finished its task`, + silent: false, + }); + + notification.on("click", () => { + window.show(); + window.focus(); + // Request focus on the specific tab + notificationsEmitter.emit("focus-tab", { + tabId: event.tabId, + workspaceId: event.workspaceId, + }); + }); + + notification.show(); + } + }); + window.webContents.on("did-finish-load", async () => { window.show(); }); - window.on("close", () => {}); + window.on("close", () => { + server.close(); + notificationsEmitter.removeAllListeners(); + }); return window; } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index bb50eb77cd8..ccc42a6559f 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -7,6 +7,7 @@ import { useReorderWorkspaces, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; +import { useTabs } from "renderer/stores"; const WORKSPACE_TYPE = "WORKSPACE"; @@ -34,6 +35,12 @@ export function WorkspaceItem({ const setActive = useSetActiveWorkspace(); const deleteWorkspace = useDeleteWorkspace(); const reorderWorkspaces = useReorderWorkspaces(); + const tabs = useTabs(); + + // Derive if workspace needs attention from any of its tabs + const needsAttention = tabs + .filter((t) => t.workspaceId === id) + .some((t) => t.needsAttention); const [{ isDragging }, drag] = useDrag( () => ({ @@ -89,6 +96,12 @@ export function WorkspaceItem({ {title} + {needsAttention && ( + + + + + )}