diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index 8f9155845b0..0ced8cdacb6 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -1,15 +1,75 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { BIN_DIR, HOOKS_DIR } from "./paths"; -import { findRealBinary } from "./utils"; +import { getNotifyScriptPath } from "./notify-hook"; +import { + BIN_DIR, + HOOKS_DIR, + OPENCODE_CONFIG_DIR, + OPENCODE_PLUGIN_DIR, +} from "./paths"; + +export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; +export const CLAUDE_SETTINGS_FILE = "claude-settings.json"; +export const OPENCODE_PLUGIN_FILE = "superset-notify.js"; +export const OPENCODE_PLUGIN_MARKER = "// Superset opencode plugin v3"; + +const REAL_BINARY_RESOLVER = `find_real_binary() { + local name="$1" + local IFS=: + for dir in $PATH; do + [ -z "$dir" ] && continue + dir="\${dir%/}" + case "$dir" in + "$HOME/.superset/bin"|"$HOME/.superset-dev/bin") continue ;; + esac + if [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ]; then + printf "%s\\n" "$dir/$name" + return 0 + fi + done + return 1 +} +`; + +function getMissingBinaryMessage(name: string): string { + return `Superset: ${name} not found in PATH. Install it and ensure it is on PATH, then retry.`; +} + +export function getClaudeWrapperPath(): string { + return path.join(BIN_DIR, "claude"); +} + +export function getCodexWrapperPath(): string { + return path.join(BIN_DIR, "codex"); +} + +export function getOpenCodeWrapperPath(): string { + return path.join(BIN_DIR, "opencode"); +} + +export function getClaudeSettingsPath(): string { + return path.join(HOOKS_DIR, CLAUDE_SETTINGS_FILE); +} + +export function getOpenCodePluginPath(): string { + return path.join(OPENCODE_PLUGIN_DIR, OPENCODE_PLUGIN_FILE); +} /** - * Creates the Claude Code settings JSON file with notification hooks + * OpenCode auto-loads plugins from ~/.config/opencode/plugin/ + * See: https://opencode.ai/docs/plugins + * The plugin checks SUPERSET_TAB_ID env var so it only activates in Superset terminals. */ -function createClaudeSettings(): string { - const settingsPath = path.join(HOOKS_DIR, "claude-settings.json"); - const notifyPath = path.join(HOOKS_DIR, "notify.sh"); +export function getOpenCodeGlobalPluginPath(): string { + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); + const configHome = xdgConfigHome?.length + ? xdgConfigHome + : path.join(os.homedir(), ".config"); + return path.join(configHome, "opencode", "plugin", OPENCODE_PLUGIN_FILE); +} +export function getClaudeSettingsContent(notifyPath: string): string { const settings = { hooks: { Stop: [{ hooks: [{ type: "command", command: notifyPath }] }], @@ -19,53 +79,228 @@ function createClaudeSettings(): string { }, }; - fs.writeFileSync(settingsPath, JSON.stringify(settings), { mode: 0o644 }); - return settingsPath; + return JSON.stringify(settings); } -/** - * Creates wrapper script for Claude Code - */ -export function createClaudeWrapper(): void { - const wrapperPath = path.join(BIN_DIR, "claude"); - const realClaude = findRealBinary("claude"); +export function buildClaudeWrapperScript(settingsPath: string): string { + return `#!/bin/bash +${WRAPPER_MARKER} +# Superset wrapper for Claude Code +# Injects notification hook settings - if (!realClaude) { - console.log("[agent-setup] Claude not found, skipping wrapper"); - return; - } +${REAL_BINARY_RESOLVER} +REAL_BIN="$(find_real_binary "claude")" +if [ -z "$REAL_BIN" ]; then + echo "${getMissingBinaryMessage("claude")}" >&2 + exit 127 +fi - const settingsPath = createClaudeSettings(); +exec "$REAL_BIN" --settings "${settingsPath}" "$@" +`; +} - const script = `#!/bin/bash -# Superset wrapper for Claude Code +export function buildCodexWrapperScript(notifyPath: string): string { + return `#!/bin/bash +${WRAPPER_MARKER} +# Superset wrapper for Codex # Injects notification hook settings -exec "${realClaude}" --settings "${settingsPath}" "$@" +${REAL_BINARY_RESOLVER} +REAL_BIN="$(find_real_binary "codex")" +if [ -z "$REAL_BIN" ]; then + echo "${getMissingBinaryMessage("codex")}" >&2 + exit 127 +fi + +exec "$REAL_BIN" -c 'notify=["bash","${notifyPath}"]' "$@" `; +} + +export function buildOpenCodeWrapperScript(opencodeConfigDir: string): string { + return `#!/bin/bash +${WRAPPER_MARKER} +# Superset wrapper for OpenCode +# Injects OPENCODE_CONFIG_DIR for notification plugin + +${REAL_BINARY_RESOLVER} +REAL_BIN="$(find_real_binary "opencode")" +if [ -z "$REAL_BIN" ]; then + echo "${getMissingBinaryMessage("opencode")}" >&2 + exit 127 +fi + +export OPENCODE_CONFIG_DIR="${opencodeConfigDir}" +exec "$REAL_BIN" "$@" +`; +} + +export function getOpenCodePluginContent(notifyPath: string): string { + // Build "${" via char codes to avoid JS template literal interpolation in generated code + const templateOpen = String.fromCharCode(36, 123); + const shellLine = ` await $\`bash ${templateOpen}notifyPath} ${templateOpen}payload}\`;`; + return [ + OPENCODE_PLUGIN_MARKER, + "/**", + " * Superset Notification Plugin for OpenCode", + " *", + " * This plugin sends desktop notifications when OpenCode sessions need attention.", + " * It hooks into session.idle, session.error, and permission.ask events.", + " *", + " * IMPORTANT: Subagent/Background Task Filtering", + " * --------------------------------------------", + " * When using oh-my-opencode or similar tools that spawn background subagents", + " * (e.g., explore, librarian, oracle agents), each subagent runs in its own", + " * OpenCode session. These child sessions emit session.idle events when they", + " * complete, which would cause excessive notifications if not filtered.", + " *", + " * How we detect child sessions:", + " * - OpenCode sessions have a `parentID` field when they are subagent sessions", + " * - Main/root sessions have `parentID` as undefined", + " * - We use client.session.list() to look up the session and check parentID", + " *", + " * Reference: OpenCode's own notification handling in packages/app/src/context/notification.tsx", + " * uses the same approach to filter out child session notifications.", + " *", + " * @see https://github.com/sst/opencode/blob/dev/packages/app/src/context/notification.tsx", + " */", + "export const SupersetNotifyPlugin = async ({ $, client }) => {", + " if (globalThis.__supersetOpencodeNotifyPluginV3) return {};", + " globalThis.__supersetOpencodeNotifyPluginV3 = true;", + "", + " // Only run inside a Superset terminal session", + " if (!process?.env?.SUPERSET_TAB_ID) return {};", + "", + ` const notifyPath = "${notifyPath}";`, + "", + " /**", + " * Sends a notification to Superset's notification server.", + " * Best-effort only - failures are silently ignored to avoid breaking the agent.", + " */", + " const notify = async (hookEventName) => {", + " const payload = JSON.stringify({ hook_event_name: hookEventName });", + " try {", + shellLine, + " } catch {", + " // Best-effort only; do not break the agent if notification fails", + " }", + " };", + "", + " /**", + " * Checks if a session is a child/subagent session by looking up its parentID.", + " *", + " * Background: When oh-my-opencode spawns background agents (explore, librarian, etc.),", + " * each agent runs in a separate OpenCode session with a parentID pointing to the", + " * main session. We only want to notify for main sessions, not subagent completions.", + " *", + " * Implementation notes:", + " * - Uses client.session.list() because it reliably returns parentID", + " * - session.get() has parameter issues in some SDK versions", + " * - This is a local RPC call (~10ms), acceptable for infrequent notification events", + " * - On error, returns false (assumes main session) to avoid missing notifications", + " *", + " * @param sessionID - The session ID from the event", + " * @returns true if this is a child/subagent session, false if main session", + " */", + " const isChildSession = async (sessionID) => {", + " if (!sessionID || !client?.session?.list) return false;", + " try {", + " const sessions = await client.session.list();", + " const session = sessions.data?.find((s) => s.id === sessionID);", + " // Sessions with parentID are child/subagent sessions", + " return !!session?.parentID;", + " } catch {", + " // On error, assume it's a main session to avoid missing notifications", + " return false;", + " }", + " };", + "", + " return {", + " event: async ({ event }) => {", + " // Handle session completion events", + ' if (event.type === "session.idle" || event.type === "session.error") {', + " const sessionID = event.properties?.sessionID;", + "", + " // Skip notifications for child/subagent sessions", + " // This prevents notification spam when background agents complete", + " if (await isChildSession(sessionID)) {", + " return;", + " }", + "", + ' await notify("Stop");', + " }", + " },", + ' "permission.ask": async (_permission, output) => {', + ' if (output.status === "ask") {', + ' await notify("PermissionRequest");', + " }", + " },", + " };", + "};", + "", + ].join("\n"); +} + +/** + * Creates the Claude Code settings JSON file with notification hooks + */ +function createClaudeSettings(): string { + const settingsPath = getClaudeSettingsPath(); + const notifyPath = getNotifyScriptPath(); + const settings = getClaudeSettingsContent(notifyPath); + + fs.writeFileSync(settingsPath, settings, { mode: 0o644 }); + return settingsPath; +} + +/** + * Creates wrapper script for Claude Code + */ +export function createClaudeWrapper(): void { + const wrapperPath = getClaudeWrapperPath(); + const settingsPath = createClaudeSettings(); + const script = buildClaudeWrapperScript(settingsPath); fs.writeFileSync(wrapperPath, script, { mode: 0o755 }); - console.log(`[agent-setup] Created Claude wrapper -> ${realClaude}`); + console.log("[agent-setup] Created Claude wrapper"); } /** * Creates wrapper script for Codex */ export function createCodexWrapper(): void { - const wrapperPath = path.join(BIN_DIR, "codex"); - const realCodex = findRealBinary("codex"); + const wrapperPath = getCodexWrapperPath(); + const notifyPath = getNotifyScriptPath(); + const script = buildCodexWrapperScript(notifyPath); + fs.writeFileSync(wrapperPath, script, { mode: 0o755 }); + console.log("[agent-setup] Created Codex wrapper"); +} - if (!realCodex) { - console.log("[agent-setup] Codex not found, skipping wrapper"); - return; +/** + * Creates OpenCode plugin file with notification hooks + */ +export function createOpenCodePlugin(): void { + const pluginPath = getOpenCodePluginPath(); + const notifyPath = getNotifyScriptPath(); + const content = getOpenCodePluginContent(notifyPath); + fs.writeFileSync(pluginPath, content, { mode: 0o644 }); + try { + const globalPluginPath = getOpenCodeGlobalPluginPath(); + fs.mkdirSync(path.dirname(globalPluginPath), { recursive: true }); + fs.writeFileSync(globalPluginPath, content, { mode: 0o644 }); + } catch (error) { + console.warn( + "[agent-setup] Failed to write global OpenCode plugin:", + error, + ); } + console.log("[agent-setup] Created OpenCode plugin"); +} - 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}"]' "$@" -`; +/** + * Creates wrapper script for OpenCode + */ +export function createOpenCodeWrapper(): void { + const wrapperPath = getOpenCodeWrapperPath(); + const script = buildOpenCodeWrapperScript(OPENCODE_CONFIG_DIR); fs.writeFileSync(wrapperPath, script, { mode: 0o755 }); - console.log(`[agent-setup] Created Codex wrapper -> ${realCodex}`); + console.log("[agent-setup] Created OpenCode wrapper"); } diff --git a/apps/desktop/src/main/lib/agent-setup/ensure-agent-hooks.ts b/apps/desktop/src/main/lib/agent-setup/ensure-agent-hooks.ts new file mode 100644 index 00000000000..958e3b0f7f8 --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/ensure-agent-hooks.ts @@ -0,0 +1,179 @@ +import { promises as fs, constants as fsConstants } from "node:fs"; +import path from "node:path"; +import { + buildClaudeWrapperScript, + buildCodexWrapperScript, + buildOpenCodeWrapperScript, + getClaudeSettingsContent, + getClaudeSettingsPath, + getClaudeWrapperPath, + getCodexWrapperPath, + getOpenCodeGlobalPluginPath, + getOpenCodePluginContent, + getOpenCodePluginPath, + getOpenCodeWrapperPath, + OPENCODE_PLUGIN_MARKER, + WRAPPER_MARKER, +} from "./agent-wrappers"; +import { + getNotifyScriptContent, + getNotifyScriptPath, + NOTIFY_SCRIPT_MARKER, +} from "./notify-hook"; +import { + BIN_DIR, + HOOKS_DIR, + OPENCODE_CONFIG_DIR, + OPENCODE_PLUGIN_DIR, +} from "./paths"; + +let inFlight: Promise | null = null; + +async function readFileIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +async function isExecutable(filePath: string): Promise { + try { + await fs.access(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function ensureScriptFile(params: { + filePath: string; + content: string; + mode: number; + marker: string; + logLabel: string; +}): Promise { + const { filePath, content, mode, marker, logLabel } = params; + const existing = await readFileIfExists(filePath); + const hasMarker = existing?.includes(marker); + + if (!existing || !hasMarker) { + await fs.writeFile(filePath, content, { mode }); + await fs.chmod(filePath, mode); + console.log(`[agent-setup] Rewrote ${logLabel}`); + return; + } + + // Only check/fix executability for files that should be executable (0o755) + const shouldBeExecutable = (mode & 0o111) !== 0; + if (shouldBeExecutable && !(await isExecutable(filePath))) { + await fs.chmod(filePath, mode); + } +} + +async function ensureClaudeSettings(): Promise { + const settingsPath = getClaudeSettingsPath(); + const notifyPath = getNotifyScriptPath(); + const existing = await readFileIfExists(settingsPath); + + if (!existing || !existing.includes('"hooks"')) { + const content = getClaudeSettingsContent(notifyPath); + await fs.writeFile(settingsPath, content, { mode: 0o644 }); + console.log("[agent-setup] Rewrote Claude settings"); + } +} + +export function ensureAgentHooks(): Promise { + if (process.platform === "win32") { + return Promise.resolve(); + } + + if (inFlight) { + return inFlight; + } + + inFlight = (async () => { + await new Promise((resolve) => setImmediate(resolve)); + + await fs.mkdir(BIN_DIR, { recursive: true }); + await fs.mkdir(HOOKS_DIR, { recursive: true }); + await fs.mkdir(OPENCODE_CONFIG_DIR, { recursive: true }); + await fs.mkdir(OPENCODE_PLUGIN_DIR, { recursive: true }); + const globalOpenCodePluginPath = getOpenCodeGlobalPluginPath(); + try { + await fs.mkdir(path.dirname(globalOpenCodePluginPath), { + recursive: true, + }); + } catch (error) { + console.warn( + "[agent-setup] Failed to create global OpenCode plugin directory:", + error, + ); + } + + const notifyPath = getNotifyScriptPath(); + await ensureScriptFile({ + filePath: notifyPath, + content: getNotifyScriptContent(), + mode: 0o755, + marker: NOTIFY_SCRIPT_MARKER, + logLabel: "notify hook", + }); + + await ensureClaudeSettings(); + + await ensureScriptFile({ + filePath: getClaudeWrapperPath(), + content: buildClaudeWrapperScript(getClaudeSettingsPath()), + mode: 0o755, + marker: WRAPPER_MARKER, + logLabel: "Claude wrapper", + }); + + await ensureScriptFile({ + filePath: getCodexWrapperPath(), + content: buildCodexWrapperScript(notifyPath), + mode: 0o755, + marker: WRAPPER_MARKER, + logLabel: "Codex wrapper", + }); + + await ensureScriptFile({ + filePath: getOpenCodePluginPath(), + content: getOpenCodePluginContent(notifyPath), + mode: 0o644, + marker: OPENCODE_PLUGIN_MARKER, + logLabel: "OpenCode plugin", + }); + + try { + await ensureScriptFile({ + filePath: globalOpenCodePluginPath, + content: getOpenCodePluginContent(notifyPath), + mode: 0o644, + marker: OPENCODE_PLUGIN_MARKER, + logLabel: "OpenCode global plugin", + }); + } catch (error) { + console.warn( + "[agent-setup] Failed to write global OpenCode plugin:", + error, + ); + } + + await ensureScriptFile({ + filePath: getOpenCodeWrapperPath(), + content: buildOpenCodeWrapperScript(OPENCODE_CONFIG_DIR), + mode: 0o755, + marker: WRAPPER_MARKER, + logLabel: "OpenCode wrapper", + }); + })().finally(() => { + inFlight = null; + }); + + return inFlight; +} diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index 8bab19b4045..d0ac5cb3ea4 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,7 +1,18 @@ import fs from "node:fs"; -import { createClaudeWrapper, createCodexWrapper } from "./agent-wrappers"; +import { + createClaudeWrapper, + createCodexWrapper, + createOpenCodePlugin, + createOpenCodeWrapper, +} from "./agent-wrappers"; import { createNotifyScript } from "./notify-hook"; -import { BASH_DIR, BIN_DIR, HOOKS_DIR, ZSH_DIR } from "./paths"; +import { + BASH_DIR, + BIN_DIR, + HOOKS_DIR, + OPENCODE_PLUGIN_DIR, + ZSH_DIR, +} from "./paths"; import { createBashWrapper, createZshWrapper, @@ -21,11 +32,14 @@ export function setupAgentHooks(): void { fs.mkdirSync(HOOKS_DIR, { recursive: true }); fs.mkdirSync(ZSH_DIR, { recursive: true }); fs.mkdirSync(BASH_DIR, { recursive: true }); + fs.mkdirSync(OPENCODE_PLUGIN_DIR, { recursive: true }); // Create scripts createNotifyScript(); createClaudeWrapper(); createCodexWrapper(); + createOpenCodePlugin(); + createOpenCodeWrapper(); // Create shell initialization wrappers createZshWrapper(); diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts index 98b1a37f928..c583486a9a5 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.ts @@ -3,13 +3,16 @@ import path from "node:path"; import { PORTS } from "shared/constants"; import { HOOKS_DIR } from "./paths"; -/** - * Creates the notify.sh script - */ -export function createNotifyScript(): void { - const notifyPath = path.join(HOOKS_DIR, "notify.sh"); - const script = `#!/bin/bash -# Superset agent notification hook +export const NOTIFY_SCRIPT_NAME = "notify.sh"; +export const NOTIFY_SCRIPT_MARKER = "# Superset agent notification hook"; + +export function getNotifyScriptPath(): string { + return path.join(HOOKS_DIR, NOTIFY_SCRIPT_NAME); +} + +export function getNotifyScriptContent(): string { + return `#!/bin/bash +${NOTIFY_SCRIPT_MARKER} # Called by CLI agents (Claude Code, Codex, etc.) when they complete or need input # Only run if inside a Superset terminal @@ -35,12 +38,22 @@ fi # Default to "Stop" if not found [ -z "$EVENT_TYPE" ] && EVENT_TYPE="Stop" +# Timeouts prevent blocking agent completion if notification server is unresponsive curl -sG "http://127.0.0.1:\${SUPERSET_PORT:-${PORTS.NOTIFICATIONS}}/hook/complete" \\ + --connect-timeout 1 --max-time 2 \\ --data-urlencode "paneId=$SUPERSET_PANE_ID" \\ --data-urlencode "tabId=$SUPERSET_TAB_ID" \\ --data-urlencode "workspaceId=$SUPERSET_WORKSPACE_ID" \\ --data-urlencode "eventType=$EVENT_TYPE" \\ > /dev/null 2>&1 `; +} + +/** + * Creates the notify.sh script + */ +export function createNotifyScript(): void { + const notifyPath = getNotifyScriptPath(); + const script = getNotifyScriptContent(); fs.writeFileSync(notifyPath, script, { mode: 0o755 }); } diff --git a/apps/desktop/src/main/lib/agent-setup/paths.ts b/apps/desktop/src/main/lib/agent-setup/paths.ts index 0d8619a22fe..68006e9c0e5 100644 --- a/apps/desktop/src/main/lib/agent-setup/paths.ts +++ b/apps/desktop/src/main/lib/agent-setup/paths.ts @@ -5,3 +5,5 @@ export const BIN_DIR = path.join(SUPERSET_HOME_DIR, "bin"); export const HOOKS_DIR = path.join(SUPERSET_HOME_DIR, "hooks"); export const ZSH_DIR = path.join(SUPERSET_HOME_DIR, "zsh"); export const BASH_DIR = path.join(SUPERSET_HOME_DIR, "bash"); +export const OPENCODE_CONFIG_DIR = path.join(HOOKS_DIR, "opencode"); +export const OPENCODE_PLUGIN_DIR = path.join(OPENCODE_CONFIG_DIR, "plugin"); diff --git a/apps/desktop/src/main/lib/notifications/server.ts b/apps/desktop/src/main/lib/notifications/server.ts index 8c5d27d07e5..a27398d46f0 100644 --- a/apps/desktop/src/main/lib/notifications/server.ts +++ b/apps/desktop/src/main/lib/notifications/server.ts @@ -16,6 +16,9 @@ export const notificationsEmitter = new EventEmitter(); const app = express(); +// Parse JSON request bodies +app.use(express.json()); + // CORS app.use((req, res, next) => { res.setHeader("Access-Control-Allow-Origin", "*"); diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 7d9c620f73f..d5931a908f1 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -2,7 +2,7 @@ import { execSync } from "node:child_process"; import os from "node:os"; import defaultShell from "default-shell"; import { PORTS } from "shared/constants"; -import { getShellEnv } from "../agent-setup"; +import { getShellEnv } from "../agent-setup/shell-wrappers"; export const FALLBACK_SHELL = os.platform() === "win32" ? "cmd.exe" : "/bin/sh"; export const SHELL_CRASH_THRESHOLD_MS = 1000; diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 79f1bf3f765..4541b75d664 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; import { track } from "main/lib/analytics"; +import { ensureAgentHooks } from "../agent-setup/ensure-agent-hooks"; import { FALLBACK_SHELL, SHELL_CRASH_THRESHOLD_MS } from "./env"; import { portManager } from "./port-manager"; import { @@ -62,14 +63,28 @@ export class TerminalManager extends EventEmitter { ): Promise { const { paneId, workspaceId, initialCommands } = params; + const agentHooksReady = ensureAgentHooks().catch((error): void => { + console.warn("[TerminalManager] Agent hook ensure failed:", error); + }); + // Create the session const session = await createSession(params, (id, data) => { this.emit(`data:${id}`, data); }); + // Match agent commands anywhere in the string (handles "cd repo && claude ...") + const agentCommandPattern = /\b(claude|codex|opencode)\b/; + const shouldAwaitAgentHooks = + initialCommands?.some((command) => agentCommandPattern.test(command)) ?? + false; + // Set up data handler - setupDataHandler(session, initialCommands, session.wasRecovered, () => - reinitializeHistory(session), + setupDataHandler( + session, + initialCommands, + session.wasRecovered, + () => reinitializeHistory(session), + shouldAwaitAgentHooks ? agentHooksReady : undefined, ); // Set up exit handler with fallback logic diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 54580184ce4..08b5c559f00 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -13,6 +13,8 @@ import type { InternalCreateSessionParams, TerminalSession } from "./types"; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; +/** Max time to wait for agent hooks before running initial commands */ +const AGENT_HOOKS_TIMEOUT_MS = 2000; export async function recoverScrollback( existingScrollback: string | null, @@ -144,9 +146,12 @@ export function setupDataHandler( initialCommands: string[] | undefined, wasRecovered: boolean, onHistoryReinit: () => Promise, + beforeInitialCommands?: Promise, ): void { - const shouldRunCommands = - !wasRecovered && initialCommands && initialCommands.length > 0; + const initialCommandString = + !wasRecovered && initialCommands && initialCommands.length > 0 + ? `${initialCommands.join(" && ")}\n` + : null; let commandsSent = false; session.pty.onData((data) => { @@ -166,12 +171,24 @@ export function setupDataHandler( session.dataBatcher.write(data); - if (shouldRunCommands && !commandsSent) { + if (initialCommandString && !commandsSent) { commandsSent = true; setTimeout(() => { if (session.isAlive) { - const cmdString = `${initialCommands.join(" && ")}\n`; - session.pty.write(cmdString); + void (async () => { + if (beforeInitialCommands) { + const timeout = new Promise((resolve) => + setTimeout(resolve, AGENT_HOOKS_TIMEOUT_MS), + ); + await Promise.race([beforeInitialCommands, timeout]).catch( + () => {}, + ); + } + + if (session.isAlive) { + session.pty.write(initialCommandString); + } + })(); } }, 100); }