From 2c6bcf57308dda8a76c329ec2daa687553b05d17 Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:09:37 +0000 Subject: [PATCH] fix(desktop): skip uninstalled agents when provisioning hooks Desktop boot no longer writes hooks into config dirs of agents that are not installed (binary on PATH or usual install dir, or config dir present). Re-provision on window focus (at most once a minute) so installing an agent after Paradev still gets its hooks without a restart. Stacked on #9219. --- apps/desktop/src/main/index.ts | 19 ++++ .../post-onboarding-setup.ts | 4 + packages/agent-setup/src/agent-setup.ts | 15 +++- packages/agent-setup/src/index.ts | 7 +- .../agent-setup/src/installed-agents.test.ts | 70 +++++++++++++++ packages/agent-setup/src/installed-agents.ts | 88 +++++++++++++++++++ 6 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 packages/agent-setup/src/installed-agents.test.ts create mode 100644 packages/agent-setup/src/installed-agents.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 2f2a5ecfa2a..77b9006e6c3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -250,6 +250,24 @@ function setupAgentIntegrationsFromSettings() { setupAgentIntegrations({ disabledAgentIds: disabledAgentHooks, disabledSkillIds: disabledSkills, + skipUninstalledAgents: true, + }); +} + +const AGENT_REPROVISION_MIN_INTERVAL_MS = 60_000; + +function reprovisionAgentIntegrationsOnFocus() { + let lastRunAt = Date.now(); + app.on("browser-window-focus", () => { + if (!postOnboardingSetup.hasRun()) return; + const now = Date.now(); + if (now - lastRunAt < AGENT_REPROVISION_MIN_INTERVAL_MS) return; + lastRunAt = now; + try { + setupAgentIntegrationsFromSettings(); + } catch (error) { + console.error("[main] Failed to refresh agent integrations:", error); + } }); } @@ -651,6 +669,7 @@ if (!gotTheLock) { setAgentSetupTemplatesDir(path.join(__dirname, "templates")); // macOS permission prompts and writes into other tools' config dirs wait // for a signed-in, onboarded user. + reprovisionAgentIntegrationsOnFocus(); postOnboardingSetup.setSteps([ ["notification-center", registerWithMacOSNotificationCenter], ["apple-events", requestAppleEventsAccess], diff --git a/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts b/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts index 1585b9227f7..a6bfdbb8903 100644 --- a/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts +++ b/apps/desktop/src/main/lib/post-onboarding-setup/post-onboarding-setup.ts @@ -3,6 +3,7 @@ export type PostOnboardingStep = readonly [label: string, run: () => void]; export interface PostOnboardingSetup { setSteps(steps: readonly PostOnboardingStep[]): void; request(): void; + hasRun(): boolean; } /** @@ -39,6 +40,9 @@ export function createPostOnboardingSetup( requested = true; runIfReady(); }, + hasRun() { + return ran; + }, }; } diff --git a/packages/agent-setup/src/agent-setup.ts b/packages/agent-setup/src/agent-setup.ts index 0f4b31565ca..1db94e6e89a 100644 --- a/packages/agent-setup/src/agent-setup.ts +++ b/packages/agent-setup/src/agent-setup.ts @@ -59,6 +59,7 @@ import { } from "./agent-wrappers"; import { createArtifactGuardScript } from "./artifact-guard-hook"; import { resolveDisabledSkillIds } from "./disabled-skills"; +import { isAgentInstalled } from "./installed-agents"; import { createManagedSkills } from "./managed-skills"; import { createNotifyScript } from "./notify-hook"; @@ -213,11 +214,19 @@ interface SetupAgentCapabilitiesOptions { disabledAgentIds?: readonly string[]; /** Skills the user disabled; withheld from provisioning and reaped. */ disabledSkillIds?: readonly string[]; + /** + * Leave agents that are not installed untouched, so no config dir is + * created for a tool the user never installed. + */ + skipUninstalledAgents?: boolean; + isInstalled?: (agentId: AgentSetupTargetId) => boolean; } export function setupAgentCapabilities({ disabledAgentIds = [], disabledSkillIds = [], + skipUninstalledAgents = false, + isInstalled = isAgentInstalled, }: SetupAgentCapabilitiesOptions = {}): void { const disabled = new Set(disabledAgentIds); const failed: string[] = []; @@ -239,9 +248,13 @@ export function setupAgentCapabilities({ for (const target of AGENT_SETUP_TARGETS) { const definition = AGENT_SETUP_DEFINITIONS[target.id]; + const isDisabled = disabled.has(target.id); + if (skipUninstalledAgents && !isDisabled && !isInstalled(target.id)) { + continue; + } runAgentActions( target.id, - disabled.has(target.id) ? definition.teardown : definition.setup, + isDisabled ? definition.teardown : definition.setup, failed, ); } diff --git a/packages/agent-setup/src/index.ts b/packages/agent-setup/src/index.ts index b09606d8709..1051fa77ba0 100644 --- a/packages/agent-setup/src/index.ts +++ b/packages/agent-setup/src/index.ts @@ -40,6 +40,7 @@ export function setupAgentIntegrations( options: { disabledAgentIds?: readonly string[]; disabledSkillIds?: readonly string[]; + skipUninstalledAgents?: boolean; } = {}, ): void { console.log("[agent-setup] Provisioning agent integrations..."); @@ -52,7 +53,11 @@ export function setupAgentIntegrations( fs.mkdirSync(getBashDir(), { recursive: true }); fs.mkdirSync(getOpenCodePluginDir(), { recursive: true }); - setupAgentCapabilities({ disabledAgentIds, disabledSkillIds }); + setupAgentCapabilities({ + disabledAgentIds, + disabledSkillIds, + skipUninstalledAgents: options.skipUninstalledAgents, + }); runSetupAction("zsh-wrapper", createZshWrapper); runSetupAction("bash-wrapper", createBashWrapper); diff --git a/packages/agent-setup/src/installed-agents.test.ts b/packages/agent-setup/src/installed-agents.test.ts new file mode 100644 index 00000000000..d7c6cacf65d --- /dev/null +++ b/packages/agent-setup/src/installed-agents.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setupAgentCapabilities } from "./agent-setup"; +import { defaultBinarySearchDirs, isAgentInstalled } from "./installed-agents"; + +const ORIGINAL = { + HOME: process.env.HOME, + PATH: process.env.PATH, + SUPERSET_HOME_DIR: process.env.SUPERSET_HOME_DIR, +}; +let home: string; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "superset-installed-")); + process.env.HOME = home; + process.env.PATH = path.join(home, "empty-bin"); + process.env.SUPERSET_HOME_DIR = path.join(home, ".superset"); +}); + +afterEach(() => { + for (const [key, value] of Object.entries(ORIGINAL)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("isAgentInstalled", () => { + it("is false on a bare home", () => { + expect(isAgentInstalled("claude", [])).toBe(false); + }); + + it("detects a config dir", () => { + fs.mkdirSync(path.join(home, ".codex")); + expect(isAgentInstalled("codex", [])).toBe(true); + }); + + it("detects a binary in a search dir", () => { + const bin = path.join(home, "bin"); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(bin, "gemini"), "#!/bin/sh\n", { mode: 0o755 }); + expect(isAgentInstalled("gemini", [bin])).toBe(true); + }); + + it("leaves Superset's own wrapper dir out of the search", () => { + const wrappers = path.join(home, ".superset", "bin"); + process.env.PATH = wrappers; + expect(defaultBinarySearchDirs()).not.toContain(wrappers); + }); +}); + +describe("setupAgentCapabilities with skipUninstalledAgents", () => { + it("writes only the installed agent's config", () => { + fs.mkdirSync(path.join(home, ".superset", "hooks"), { recursive: true }); + fs.mkdirSync(path.join(home, ".superset", "bin"), { recursive: true }); + setupAgentCapabilities({ + skipUninstalledAgents: true, + isInstalled: (id) => id === "claude", + }); + + expect(fs.existsSync(path.join(home, ".claude", "settings.json"))).toBe( + true, + ); + for (const dir of [".codex", ".cursor", ".gemini", ".grok", ".factory"]) { + expect(fs.existsSync(path.join(home, dir))).toBe(false); + } + }); +}); diff --git a/packages/agent-setup/src/installed-agents.ts b/packages/agent-setup/src/installed-agents.ts new file mode 100644 index 00000000000..f4ca9ee947f --- /dev/null +++ b/packages/agent-setup/src/installed-agents.ts @@ -0,0 +1,88 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { AgentSetupTargetId } from "./agent-setup-targets"; +import { getBinDir, resolveUserHomeDir } from "./paths"; + +interface AgentFootprint { + binaries: readonly string[]; + homeDirs: readonly string[]; +} + +const AGENT_FOOTPRINTS: Record = { + amp: { binaries: ["amp"], homeDirs: [".config/amp"] }, + claude: { binaries: ["claude"], homeDirs: [".claude"] }, + codex: { binaries: ["codex"], homeDirs: [".codex"] }, + droid: { binaries: ["droid"], homeDirs: [".factory"] }, + opencode: { binaries: ["opencode"], homeDirs: [".config/opencode"] }, + omp: { binaries: ["omp"], homeDirs: [".omp"] }, + pi: { binaries: ["pi"], homeDirs: [".pi"] }, + "cursor-agent": { + binaries: ["cursor-agent"], + homeDirs: [".cursor"], + }, + gemini: { binaries: ["gemini"], homeDirs: [".gemini"] }, + mastracode: { binaries: ["mastracode"], homeDirs: [".mastracode"] }, + kimi: { binaries: ["kimi"], homeDirs: [".kimi-code"] }, + grok: { binaries: ["grok"], homeDirs: [".grok"] }, + copilot: { binaries: ["copilot"], homeDirs: [".copilot"] }, + vibe: { binaries: ["vibe"], homeDirs: [".vibe"] }, + devin: { binaries: ["devin"], homeDirs: [".config/devin"] }, + muse: { binaries: ["muse"], homeDirs: [".config/muse"] }, + ufo: { binaries: ["ufo"], homeDirs: [] }, + agy: { binaries: ["agy"], homeDirs: [] }, +}; + +const EXTRA_BINARY_DIRS = [ + ".local/bin", + ".bun/bin", + ".cargo/bin", + ".npm-global/bin", + ".claude/local", + ".volta/bin", +]; + +const SYSTEM_BINARY_DIRS = ["/opt/homebrew/bin", "/usr/local/bin"]; + +export function defaultBinarySearchDirs(): string[] { + const home = resolveUserHomeDir(); + const wrapperDir = path.resolve(getBinDir()); + const fromPath = (process.env.PATH ?? "").split(path.delimiter); + return [ + ...fromPath, + ...EXTRA_BINARY_DIRS.map((dir) => path.join(home, dir)), + ...SYSTEM_BINARY_DIRS, + ].filter((dir) => dir && path.resolve(dir) !== wrapperDir); +} + +function hasBinary(names: readonly string[], dirs: readonly string[]): boolean { + const extensions = process.platform === "win32" ? ["", ".exe", ".cmd"] : [""]; + return names.some((name) => + dirs.some((dir) => + extensions.some((extension) => { + try { + fs.accessSync(path.join(dir, name + extension), fs.constants.X_OK); + return true; + } catch { + return false; + } + }), + ), + ); +} + +/** + * An agent counts as installed when its binary is on PATH (or a usual install + * dir — Finder-launched apps get a minimal PATH) or its home config dir + * exists. Superset's own wrapper dir is ignored. + */ +export function isAgentInstalled( + agentId: AgentSetupTargetId, + binaryDirs: readonly string[] = defaultBinarySearchDirs(), +): boolean { + const footprint = AGENT_FOOTPRINTS[agentId]; + const home = resolveUserHomeDir(); + if (footprint.homeDirs.some((dir) => fs.existsSync(path.join(home, dir)))) { + return true; + } + return hasBinary(footprint.binaries, binaryDirs); +}