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/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
@AGENTS.md
@AGENTS.md
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,7 @@ export const createAppRouter = (window: BrowserWindow) => {
projects: createProjectsRouter(window),
workspaces: createWorkspacesRouter(),
terminal: createTerminalRouter(),
notifications: createNotificationsRouter(),
});
};

Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/lib/trpc/routers/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationEvent>((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);
};
});
}),
});
};
19 changes: 17 additions & 2 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand All @@ -38,6 +51,8 @@ export const createTerminalRouter = () => {
const result = await terminalManager.createOrAttach({
tabId,
workspaceId,
tabTitle,
workspaceName,
cwd,
cols,
rows,
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions apps/desktop/src/main/lib/agent-setup.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Comment thread
Kitenite marked this conversation as resolved.

/**
* 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 });
}
Comment on lines +33 to +50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use NOTIFICATIONS_PORT constant for consistency.

The port is hardcoded as 31415 in the notify script, but the AI summary indicates that NOTIFICATIONS_PORT is defined in apps/desktop/src/shared/constants.ts. This creates a potential mismatch if the port constant differs from the hardcoded value.

Consider passing the NOTIFICATIONS_PORT value into the script generation:

-function createNotifyScript(): void {
+function createNotifyScript(port: number): 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" \\
+curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${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 });
 }

Then import and pass NOTIFICATIONS_PORT when calling this function.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/agent-setup.ts around lines 32 to 49 the notify
script hardcodes port 31415; update to use the NOTIFICATIONS_PORT constant
instead by importing NOTIFICATIONS_PORT from
apps/desktop/src/shared/constants.ts, change createNotifyScript to accept a port
parameter (or read the imported constant inside the function), interpolate that
port into the curl URL (replacing 31415), and update all call sites to pass the
constant so the script and constant remain consistent.


/**
* 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}`);
}
Comment on lines +55 to +74
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use proper path construction instead of hardcoding ~/.superset.

The hook path is hardcoded as ~/.superset/hooks/notify.sh in the generated script, which may not work if the home directory path contains special characters or in edge cases where ~ doesn't expand properly.

Apply this diff to use the actual resolved path:

 function createClaudeWrapper(): void {
 	const wrapperPath = path.join(BIN_DIR, "claude");
 	const realClaude = findRealBinary("claude");
+	const notifyPath = path.join(HOOKS_DIR, "notify.sh");
 
 	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"}]}]}}'
+SUPERSET_CLAUDE_SETTINGS='{"hooks":{"Stop":[{"matcher":"","hooks":[{"type":"command","command":"${notifyPath}"}]}]}}'
 
 exec "${realClaude}" --settings "$SUPERSET_CLAUDE_SETTINGS" "$@"
 `;
 	fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
 	console.log(`[agent-setup] Created Claude wrapper -> ${realClaude}`);
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/agent-setup.ts around lines 54 to 73, the generated
Claude wrapper hardcodes the hook as "~/.superset/hooks/notify.sh" which can
fail because "~" may not expand; resolve the actual user home and build the full
hook path instead (e.g. use Node's os.homedir() or process.env.HOME and
path.join to construct the superset hooks path), inject that resolved, properly
escaped/quoted absolute path into the script string, and write the wrapper with
the computed path so the wrapper calls the real notify.sh reliably across
environments.


/**
* 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");
}
Comment on lines +103 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for filesystem operations.

The function performs multiple filesystem operations without error handling. If directory creation or script writing fails (e.g., due to permissions), the app will crash on startup.

Apply this diff to add basic error handling:

 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");
+	try {
+		// 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");
+	} catch (error) {
+		console.error("[agent-setup] Failed to initialize agent hooks:", error);
+		// Optionally: decide if this should be a fatal error or just log and continue
+	}
 }
🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/agent-setup.ts around lines 102 to 115, the
filesystem operations (fs.mkdirSync and the create* script calls) lack error
handling and can crash the app on permission or I/O failures; wrap the directory
creation and script-creation calls in a try/catch (either per-operation or a
single block) and on error log a clear message with the caught error
(console.error or a logger) and terminate gracefully (process.exit(1)) or
rethrow to surface the failure; ensure any synchronous operations remain
synchronous inside the try/catch and preserve the existing success log only when
no error occurred.


/**
* 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;
}
57 changes: 57 additions & 0 deletions apps/desktop/src/main/lib/notifications/server.ts
Original file line number Diff line number Diff line change
@@ -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 };
21 changes: 19 additions & 2 deletions apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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();

Expand All @@ -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(
Expand Down
Loading
Loading