From 82b15e4d13e807b2adf3f7b50f64ed94c28c731c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 20 Nov 2025 22:24:59 -0800 Subject: [PATCH 1/3] WIP - kinda works, cursor hook not working, need to test integration with terminals + ensure wrapper works --- apps/desktop/CLAUDE.md | 32 +++++ .../src/lib/trpc/routers/terminal/terminal.ts | 11 ++ apps/desktop/src/main/index.ts | 4 + apps/desktop/src/main/lib/agent-setup.ts | 128 ++++++++++++++++++ apps/desktop/src/main/lib/hooks-server.ts | 100 ++++++++++++++ apps/desktop/src/main/windows/main.ts | 8 +- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 13 ++ .../Sidebar/TabsView/TabItem/index.tsx | 17 ++- .../src/renderer/screens/main/index.tsx | 4 + .../desktop/src/renderer/stores/tabs/hooks.ts | 2 + .../desktop/src/renderer/stores/tabs/index.ts | 1 + .../desktop/src/renderer/stores/tabs/store.ts | 9 ++ .../desktop/src/renderer/stores/tabs/types.ts | 2 + .../stores/tabs/useAgentHookListener.ts | 28 ++++ 14 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/lib/agent-setup.ts create mode 100644 apps/desktop/src/main/lib/hooks-server.ts create mode 100644 apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index b1c564345c0..e3f6d5526de 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -1,3 +1,35 @@ +# Agent Notification System + +The desktop app can show a notification (red dot) on tabs when an agent like Claude finishes its task. + +## How It Works + +1. **HTTP Hooks Server** (`src/main/lib/hooks-server.ts`): Listens on port 31415 for completion callbacks +2. **IPC Event**: When a hook fires, sends `agent-hook:complete` event to renderer +3. **Zustand Update**: `useAgentHookListener` hook updates tab's `needsAttention` flag +4. **UI**: TabItem shows red dot when `needsAttention` is true, clears on click + +## Auto-Setup (No User Configuration Required) + +On app startup, `setupAgentHooks()` in `src/main/lib/agent-setup.ts`: +1. Creates `~/.superset/bin/` with wrapper scripts for `claude` and `codex` +2. Creates `~/.superset/hooks/notify.sh` shared notification script +3. Wrapper scripts inject hook settings via CLI flags (e.g., `--settings` for Claude) + +When terminals spawn, PATH is prepended with `~/.superset/bin/` so our wrappers intercept agent commands. + +## Terminal Integration + +When creating terminals, pass these env vars via node-pty: +- `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 (31415) + +See `src/lib/trpc/routers/terminal/terminal.ts` for the interface. + +--- + # Implementation details For Electron interprocess communnication, ALWAYS use trpc as defined in `src/lib/trpc` Please use alias as defined in `tsconfig.json` when possible diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index e4d53cfee27..df79cc91245 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({ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d5ca6ff23db..911c01703bc 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 @@ -36,6 +37,9 @@ registerStorageHandlers(); // Initialize database await initDb(); + // Set up agent hook wrappers in ~/.superset + setupAgentHooks(); + 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..d259543972f --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +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:-31415}/hook/complete" \\ + --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ + --data-urlencode "tabTitle=$SUPERSET_TAB_TITLE" \\ + --data-urlencode "workspaceName=$SUPERSET_WORKSPACE_NAME" \\ + > /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 getSupsersetPath(): 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/hooks-server.ts b/apps/desktop/src/main/lib/hooks-server.ts new file mode 100644 index 00000000000..7d6c588fde6 --- /dev/null +++ b/apps/desktop/src/main/lib/hooks-server.ts @@ -0,0 +1,100 @@ +import http from "node:http"; +import { Notification, type BrowserWindow } from "electron"; + +const DEFAULT_PORT = 31415; + +let server: http.Server | null = null; + +/** + * Starts an HTTP server that listens for agent hook callbacks. + * Claude's Stop hook can call: curl "http://localhost:PORT/hook/complete?tabId=XXX" + */ +export function startHooksServer(window: BrowserWindow): number { + const port = DEFAULT_PORT; + + server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + + // CORS headers for flexibility + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + + if (url.pathname === "/hook/complete") { + const tabId = url.searchParams.get("tabId"); + const tabTitle = url.searchParams.get("tabTitle") || "Terminal"; + const workspaceName = url.searchParams.get("workspaceName") || "Workspace"; + + if (!tabId) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing tabId parameter" })); + return; + } + + // Send event to renderer to update tab state + window.webContents.send("agent-hook:complete", { tabId }); + + // Show native push notification + if (Notification.isSupported()) { + const notification = new Notification({ + title: `Agent Complete — ${workspaceName}`, + body: `"${tabTitle}" has finished its task`, + silent: false, + }); + notification.on("click", () => { + window.show(); + window.focus(); + // Clear the attention state when notification is clicked + window.webContents.send("agent-hook:dismiss", { tabId }); + }); + notification.show(); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true, tabId })); + return; + } + + // Health check endpoint + if (url.pathname === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + }); + + server.listen(port, "127.0.0.1", () => { + console.log(`[hooks-server] Listening on http://127.0.0.1:${port}`); + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.error( + `[hooks-server] Port ${port} is already in use. Hook callbacks will not work.`, + ); + } else { + console.error("[hooks-server] Server error:", err); + } + }); + + return port; +} + +export function stopHooksServer(): void { + if (server) { + server.close(); + server = null; + } +} + +export function getHooksServerPort(): number { + return DEFAULT_PORT; +} diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 8fd89c14080..9cd4d97a7f4 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -5,6 +5,7 @@ import { createAppRouter } from "lib/trpc/routers"; import { createIPCHandler } from "trpc-electron/main"; import { displayName } from "~/package.json"; import { createApplicationMenu } from "../lib/menu"; +import { startHooksServer, stopHooksServer } from "../lib/hooks-server"; export async function MainWindow() { const { width, height } = screen.getPrimaryDisplay().workAreaSize; @@ -38,11 +39,16 @@ export async function MainWindow() { windows: [window], }); + // Start HTTP server for agent hook callbacks + startHooksServer(window); + window.webContents.on("did-finish-load", async () => { window.show(); }); - window.on("close", () => {}); + window.on("close", () => { + stopHooksServer(); + }); 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 548d2e4a93c..bf7b4d9a851 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 && ( + + + + + )}