diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index 3c8155e331..183b06bddd 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -268,7 +268,7 @@ describe("setup dashboard command", () => { vi.unstubAllGlobals(); }); - it("writes dashboard notifier config with the urgent-action routing default", async () => { + it("writes dashboard notifier config with the all-priorities routing default", async () => { const program = createProgram(); await program.parseAsync([ @@ -290,8 +290,8 @@ describe("setup dashboard command", () => { expect(parsed.notifiers?.["dashboard"]).toEqual({ plugin: "dashboard", limit: 75 }); expect(parsed.notificationRouting?.urgent).toContain("dashboard"); expect(parsed.notificationRouting?.action).toContain("dashboard"); - expect(parsed.notificationRouting?.warning ?? []).not.toContain("dashboard"); - expect(parsed.notificationRouting?.info ?? []).not.toContain("dashboard"); + expect(parsed.notificationRouting?.warning).toContain("dashboard"); + expect(parsed.notificationRouting?.info).toContain("dashboard"); }); it("prints status without mutating config", async () => { @@ -2717,7 +2717,7 @@ describe("setup desktop command", () => { expect(setup?.commands.some((command) => command.name() === "desktop")).toBe(true); }); - it("installs the bundled app and wires desktop routing to all priorities", async () => { + it("installs the bundled app and wires desktop routing to urgent only", async () => { const program = createProgram(); await program.parseAsync(["node", "test", "setup", "desktop", "--non-interactive"]); @@ -2735,9 +2735,9 @@ describe("setup desktop command", () => { dashboardUrl: "http://localhost:3000", }); expect(parsed.notificationRouting?.["urgent"]).toContain("desktop"); - expect(parsed.notificationRouting?.["action"]).toContain("desktop"); - expect(parsed.notificationRouting?.["warning"]).toContain("desktop"); - expect(parsed.notificationRouting?.["info"]).toContain("desktop"); + expect(parsed.notificationRouting?.["action"] ?? []).not.toContain("desktop"); + expect(parsed.notificationRouting?.["warning"] ?? []).not.toContain("desktop"); + expect(parsed.notificationRouting?.["info"] ?? []).not.toContain("desktop"); }); it("configures terminal-notifier backend without installing AO Notifier.app", async () => { @@ -2978,7 +2978,7 @@ projects: defaults?: { notifiers?: string[] }; }; expect(parsed.notificationRouting?.["urgent"]).toEqual(["slack", "desktop"]); - expect(parsed.notificationRouting?.["action"]).toEqual(["slack", "desktop"]); + expect(parsed.notificationRouting?.["action"]).toEqual(["slack"]); expect(parsed.defaults?.notifiers).toEqual(["slack"]); }); diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 1b7a3f5935..e0c8fd180a 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -19,11 +19,7 @@ import { basename, join } from "node:path"; import { tmpdir } from "node:os"; import { parse as parseYaml } from "yaml"; import { EventEmitter } from "node:events"; -import { - getDefaultRuntime, - recordActivityEvent, - type SessionManager, -} from "@aoagents/ao-core"; +import { getDefaultRuntime, recordActivityEvent, type SessionManager } from "@aoagents/ao-core"; // --------------------------------------------------------------------------- // Hoisted mocks @@ -2366,7 +2362,7 @@ describe("start command — platform-aware runtime fallback", () => { // --------------------------------------------------------------------------- describe("start command — autoCreateConfig", () => { - it("writes a flat local config and returns the global project identity", async () => { + it("writes a flat local config, returns global project identity, and creates startup notifier defaults", async () => { const { detectEnvironment } = await import("../../src/lib/detect-env.js"); vi.mocked(detectEnvironment).mockResolvedValue({ isGitRepo: true, @@ -2418,15 +2414,40 @@ describe("start command — autoCreateConfig", () => { const globalContent = readFileSync(globalConfigPath, "utf-8"); const globalParsed = parseYaml(globalContent) as { defaults?: { notifiers?: unknown[] }; + notifiers?: Record< + string, + { plugin?: string; backend?: string; dashboardUrl?: string; limit?: number } + >; + notificationRouting?: Record; projects?: Record; }; - expect(globalParsed.defaults?.notifiers).toEqual(["composio", "desktop"]); + expect(globalParsed.defaults?.notifiers).toEqual([]); + expect(globalParsed.notifiers?.["dashboard"]).toEqual({ plugin: "dashboard", limit: 50 }); + expect(globalParsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "ao-app", + dashboardUrl: "http://localhost:3000", + }); + expect(globalParsed.notificationRouting).toEqual({ + urgent: ["desktop", "dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }); + expect(globalContent).not.toContain("composio"); const projectIds = Object.keys(globalParsed.projects ?? {}); expect(projectIds).toHaveLength(1); expect(config.configPath).toBe(configPath); expect(Object.keys(config.projects)).toEqual(projectIds); expect(config.projects[projectIds[0]!]!.path).toBe(realpathSync(tmpDir)); + expect(config.defaults.notifiers).toEqual([]); + expect(config.notificationRouting).toEqual({ + urgent: ["desktop", "dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }); }); it("removes the flat local config when global registration fails", async () => { @@ -2459,12 +2480,9 @@ describe("start command — autoCreateConfig", () => { writeFileSync( process.env["AO_GLOBAL_CONFIG"]!, - [ - "projects:", - ` ${basename(tmpDir)}:`, - ` path: ${join(tmpDir, "other-repo")}`, - "", - ].join("\n"), + ["projects:", ` ${basename(tmpDir)}:`, ` path: ${join(tmpDir, "other-repo")}`, ""].join( + "\n", + ), ); await expect(autoCreateConfig(tmpDir)).rejects.toThrow("already registered"); diff --git a/packages/cli/__tests__/lib/path-equality.test.ts b/packages/cli/__tests__/lib/path-equality.test.ts index dd3c86bcbe..68bb3db4eb 100644 --- a/packages/cli/__tests__/lib/path-equality.test.ts +++ b/packages/cli/__tests__/lib/path-equality.test.ts @@ -82,8 +82,7 @@ describe("canonicalCompareKey", () => { process.env["HOME"] = tmpDir; try { const key = canonicalCompareKey("~"); - // On Windows the result is lowercased; on POSIX it's case-preserved. - expect(key.toLowerCase()).toBe(tmpDir.toLowerCase()); + expect(key).toBe(canonicalCompareKey(tmpDir)); } finally { if (originalHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = originalHome; diff --git a/packages/cli/__tests__/lib/startup-notifier-defaults.test.ts b/packages/cli/__tests__/lib/startup-notifier-defaults.test.ts new file mode 100644 index 0000000000..240157ebed --- /dev/null +++ b/packages/cli/__tests__/lib/startup-notifier-defaults.test.ts @@ -0,0 +1,229 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; +import { ensureStartupNotifierDefaults } from "../../src/lib/startup-notifier-defaults.js"; + +describe("startup notifier defaults", () => { + let tempDir: string; + let configPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "ao-startup-notifiers-")); + configPath = join(tempDir, "agent-orchestrator.yaml"); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + function readConfig(): { + defaults?: { notifiers?: string[] }; + notifiers?: Record< + string, + { plugin?: string; backend?: string; dashboardUrl?: string; limit?: number } + >; + notificationRouting?: Record; + } { + return parseYaml(readFileSync(configPath, "utf-8")) as { + defaults?: { notifiers?: string[] }; + notifiers?: Record< + string, + { plugin?: string; backend?: string; dashboardUrl?: string; limit?: number } + >; + notificationRouting?: Record; + }; + } + + it("replaces legacy implicit Composio routes with dashboard all-priority and desktop urgent-only defaults", () => { + writeFileSync( + configPath, + [ + "port: 3000", + "defaults:", + " notifiers:", + " - composio", + " - desktop", + "notifiers: {}", + "notificationRouting:", + " urgent: [desktop, composio]", + " action: [desktop, composio]", + " warning: [composio]", + " info: [composio]", + "projects: {}", + "", + ].join("\n"), + ); + + expect( + ensureStartupNotifierDefaults({ + configPath, + dashboardUrl: "http://localhost:3000", + desktopMode: "enable", + }), + ).toBe(true); + + const parsed = readConfig(); + expect(parsed.defaults?.notifiers).toEqual([]); + expect(parsed.notifiers?.["dashboard"]).toEqual({ plugin: "dashboard", limit: 50 }); + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "ao-app", + dashboardUrl: "http://localhost:3000", + }); + expect(parsed.notificationRouting).toEqual({ + urgent: ["desktop", "dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }); + expect(readFileSync(configPath, "utf-8")).not.toContain("composio"); + }); + + it("removes default desktop routing when startup desktop setup cannot complete", () => { + writeFileSync( + configPath, + [ + "port: 3000", + "defaults:", + " notifiers: []", + "notifiers:", + " desktop:", + " plugin: desktop", + " backend: ao-app", + " dashboardUrl: http://localhost:3000", + " dashboard:", + " plugin: dashboard", + " limit: 50", + "notificationRouting:", + " urgent: [desktop, dashboard]", + " action: [dashboard]", + " warning: [dashboard]", + " info: [dashboard]", + "projects: {}", + "", + ].join("\n"), + ); + + expect( + ensureStartupNotifierDefaults({ + configPath, + dashboardUrl: "http://localhost:3000", + desktopMode: "disable-default", + }), + ).toBe(true); + + const parsed = readConfig(); + expect(parsed.notifiers?.["desktop"]).toBeUndefined(); + expect(parsed.notificationRouting).toEqual({ + urgent: ["dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }); + }); + + it("preserves custom desktop routing when startup AO Notifier.app setup cannot complete", () => { + writeFileSync( + configPath, + [ + "port: 3000", + "defaults:", + " notifiers: []", + "notifiers:", + " desktop:", + " plugin: desktop", + " backend: terminal-notifier", + " dashboardUrl: http://localhost:3000", + "notificationRouting:", + " urgent: [desktop]", + "projects: {}", + "", + ].join("\n"), + ); + + expect( + ensureStartupNotifierDefaults({ + configPath, + dashboardUrl: "http://localhost:3000", + desktopMode: "disable-default", + }), + ).toBe(true); + + const parsed = readConfig(); + expect(parsed.notifiers?.["desktop"]).toMatchObject({ + plugin: "desktop", + backend: "terminal-notifier", + }); + expect(parsed.notificationRouting?.urgent).toEqual(["desktop", "dashboard"]); + }); + + it("preserves defaults.notifiers fallback semantics when writing startup routes", () => { + writeFileSync( + configPath, + [ + "port: 3000", + "defaults:", + " notifiers:", + " - slack", + "notifiers:", + " slack:", + " plugin: slack", + " webhookUrl: https://hooks.slack.com/services/T/B/C", + "projects: {}", + "", + ].join("\n"), + ); + + expect( + ensureStartupNotifierDefaults({ + configPath, + dashboardUrl: "http://localhost:3000", + desktopMode: "enable", + }), + ).toBe(true); + + const parsed = readConfig(); + expect(parsed.defaults?.notifiers).toEqual(["slack"]); + expect(parsed.notificationRouting).toEqual({ + urgent: ["slack", "desktop", "dashboard"], + action: ["slack", "dashboard"], + warning: ["slack", "dashboard"], + info: ["slack", "dashboard"], + }); + }); + + it("preserves configured manual opt-in notifiers while removing only implicit manual defaults", () => { + writeFileSync( + configPath, + [ + "port: 3000", + "defaults:", + " notifiers:", + " - slack", + " - composio", + "notifiers:", + " slack:", + " plugin: slack", + " webhookUrl: https://hooks.slack.com/services/T/B/C", + "notificationRouting:", + " urgent: [slack, composio]", + "projects: {}", + "", + ].join("\n"), + ); + + ensureStartupNotifierDefaults({ + configPath, + dashboardUrl: "http://localhost:3000", + desktopMode: "enable", + }); + + const parsed = readConfig(); + expect(parsed.defaults?.notifiers).toEqual(["slack"]); + expect(parsed.notificationRouting?.urgent).toEqual(["slack", "desktop", "dashboard"]); + expect(parsed.notifiers?.["slack"]?.plugin).toBe("slack"); + expect(readFileSync(configPath, "utf-8")).not.toContain("composio"); + }); +}); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 4f5d86eb01..e56390f93d 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -110,6 +110,8 @@ import { installShutdownHandlers, isShutdownInProgress } from "../lib/shutdown.j import { resolveOrCreateProject } from "../lib/resolve-project.js"; import { pathsEqual } from "../lib/path-equality.js"; import { maybePromptForUpdateChannel } from "../lib/update-channel-onboarding.js"; +import { ensureStartupNotifierDefaults } from "../lib/startup-notifier-defaults.js"; +import { installAoNotifierAppForStartup } from "../lib/desktop-setup.js"; import { DEFAULT_PORT } from "../lib/constants.js"; import { projectSessionUrl } from "../lib/routes.js"; @@ -129,6 +131,54 @@ function isCliFailureEventRecordedError(err: unknown): boolean { return err instanceof CliFailureEventRecordedError; } +function resolveStartupNotifierConfigPath(configPath: string): string { + if (isCanonicalGlobalConfigPath(configPath)) return configPath; + + try { + const parsed = yamlParse(readFileSync(configPath, "utf-8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && !("projects" in parsed)) { + const globalPath = getGlobalConfigPath(); + if (existsSync(globalPath)) return globalPath; + } + } catch { + // Fall through to the loaded config path. Notifier onboarding is best-effort. + } + + return configPath; +} + +async function ensureDefaultStartupNotifiers( + config: OrchestratorConfig, +): Promise { + if (!config.configPath || !existsSync(config.configPath)) return config; + + const targetConfigPath = resolveStartupNotifierConfigPath(config.configPath); + const dashboardUrl = `http://localhost:${config.port ?? DEFAULT_PORT}`; + let desktopMode: "enable" | "disable-default" = "enable"; + + if (isMac()) { + try { + await installAoNotifierAppForStartup(); + } catch (error) { + desktopMode = "disable-default"; + const message = error instanceof Error ? error.message : String(error); + console.log( + chalk.yellow( + "⚠ Could not set up AO Notifier.app; continuing with dashboard notifications only.", + ), + ); + console.log(chalk.dim(` ${message}`)); + } + } + + const changed = ensureStartupNotifierDefaults({ + configPath: targetConfigPath, + dashboardUrl, + desktopMode, + }); + return changed ? loadConfig(targetConfigPath) : config; +} + function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig { const localConfig = loadLocalProjectConfigDetailed(projectPath); if (localConfig.kind === "loaded") { @@ -874,6 +924,8 @@ async function runStartup( // feature ships. No-op on subsequent runs (idempotent — guarded by the // presence of `updateChannel` in the global config). await maybePromptForUpdateChannel(); + config = await ensureDefaultStartupNotifiers(config); + project = config.projects[projectId] ?? project; // Install the parent shutdown path before spawning any managed children. // This guarantees a SIGINT/SIGTERM in the middle of startup still performs @@ -1843,303 +1895,306 @@ export function registerStop(program: Command): void { .option("--purge-session", "Delete mapped OpenCode session when stopping") .option("--all", "Stop all running AO instances") .option("-y, --yes", "Confirm stopping active sessions without prompting") - .action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {}) => { - recordActivityEvent({ - source: "cli", - kind: "cli.stop_invoked", - level: "info", - summary: "ao stop invoked", - data: { - projectArg: projectArg ?? null, - all: opts.all === true, - purgeSession: opts.purgeSession === true, - }, - }); - try { - // Check running.json first - const running = await getRunning(); + .action( + async ( + projectArg?: string, + opts: { purgeSession?: boolean; all?: boolean; yes?: boolean } = {}, + ) => { + recordActivityEvent({ + source: "cli", + kind: "cli.stop_invoked", + level: "info", + summary: "ao stop invoked", + data: { + projectArg: projectArg ?? null, + all: opts.all === true, + purgeSession: opts.purgeSession === true, + }, + }); + try { + // Check running.json first + const running = await getRunning(); + + if (opts.all) { + // --all: kill via running.json if available, then fallback to config + if (running) { + // Sweep detached Windows pty-hosts BEFORE killing the parent. + // detached:true puts them outside the parent's process tree, so + // taskkill /T cannot reach them. The sweep speaks the named-pipe + // protocol so node-pty disposes ConPTY gracefully (avoids WER + // 0x800700e8). No-op on non-Windows. + await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); + // killProcessTree handles process trees on Windows (taskkill /T /F) + // and process groups on Unix; it swallows "already dead" internally. + await killProcessTree(running.pid, "SIGTERM"); + await unregister(); + console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`)); + console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`)); + } else { + console.log(chalk.yellow("No running AO instance found in running.json.")); + } + return; + } - if (opts.all) { - // --all: kill via running.json if available, then fallback to config - if (running) { - // Sweep detached Windows pty-hosts BEFORE killing the parent. - // detached:true puts them outside the parent's process tree, so - // taskkill /T cannot reach them. The sweep speaks the named-pipe - // protocol so node-pty disposes ConPTY gracefully (avoids WER - // 0x800700e8). No-op on non-Windows. - await sweepWindowsPtyHostsBeforeParentKill(); - await sweepRegisteredDaemonChildren(running.pid); - // killProcessTree handles process trees on Windows (taskkill /T /F) - // and process groups on Unix; it swallows "already dead" internally. - await killProcessTree(running.pid, "SIGTERM"); - await unregister(); - console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`)); - console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`)); + let config = loadConfig(); + // ao stop affects all projects (it kills the parent ao start process), + // so load the global config which has all registered projects. + // When a specific project is targeted, only fall back to global if + // the project isn't in the local config. + if (!projectArg || !config.projects[projectArg]) { + const globalPath = getGlobalConfigPath(); + if (existsSync(globalPath)) { + config = loadConfig(globalPath); + } + } + let _projectId: string; + let project: ProjectConfig; + if (projectArg) { + ({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop")); } else { - console.log(chalk.yellow("No running AO instance found in running.json.")); + const projectIds = Object.keys(config.projects); + if (projectIds.length === 0) { + throw new Error("No projects configured. Add a project to agent-orchestrator.yaml."); + } + const currentDir = resolve(cwd()); + const cwdProjectId = findProjectForDirectory(config.projects, currentDir); + _projectId = + running?.projects.find((id) => config.projects[id]) ?? cwdProjectId ?? projectIds[0]; + project = config.projects[_projectId]; } - return; - } + const port = config.port ?? DEFAULT_PORT; - let config = loadConfig(); - // ao stop affects all projects (it kills the parent ao start process), - // so load the global config which has all registered projects. - // When a specific project is targeted, only fall back to global if - // the project isn't in the local config. - if (!projectArg || !config.projects[projectArg]) { - const globalPath = getGlobalConfigPath(); - if (existsSync(globalPath)) { - config = loadConfig(globalPath); - } - } - let _projectId: string; - let project: ProjectConfig; - if (projectArg) { - ({ projectId: _projectId, project } = await resolveProject(config, projectArg, "stop")); - } else { - const projectIds = Object.keys(config.projects); - if (projectIds.length === 0) { - throw new Error("No projects configured. Add a project to agent-orchestrator.yaml."); + if (projectArg) { + console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); + } else { + console.log(chalk.bold(`\nStopping AO across all projects\n`)); } - const currentDir = resolve(cwd()); - const cwdProjectId = findProjectForDirectory(config.projects, currentDir); - _projectId = - running?.projects.find((id) => config.projects[id]) ?? - cwdProjectId ?? - projectIds[0]; - project = config.projects[_projectId]; - } - const port = config.port ?? DEFAULT_PORT; - if (projectArg) { - console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); - } else { - console.log(chalk.bold(`\nStopping AO across all projects\n`)); - } + const sm = await getSessionManager(config); + try { + // When no explicit project is given, list ALL sessions — ao stop + // kills the parent process which affects all projects. When a + // specific project is targeted, scope to that project only. + const stopAll = !projectArg; + const rawSessions = await sm.list(stopAll ? undefined : _projectId); + // Defensive consumer-side filter. `sm.list(projectId)` already scopes + // to the named project, but the kill loop hard-stops processes — a + // contract regression here would silently kill another project's + // work. When a project arg is given, drop anything that isn't ours. + const allSessions = stopAll + ? rawSessions + : rawSessions.filter((s) => s.projectId === _projectId); + const activeSessions = allSessions.filter((s) => !isTerminalSession(s)); + const killedSessionIds: string[] = []; + + // Separate sessions by project for display and recording + const targetActive = activeSessions.filter((s) => s.projectId === _projectId); + const otherActive = activeSessions.filter((s) => s.projectId !== _projectId); + // Group other-project sessions by projectId (used for display + recording) + const otherByProject = new Map(); + + if (activeSessions.length > 0) { + if (!projectArg && opts.yes !== true && isHumanCaller()) { + const confirmed = await promptConfirm( + `Stop AO and ${activeSessions.length} active session(s)?`, + false, + ); + if (!confirmed) { + console.log(chalk.yellow("Stop cancelled.")); + return; + } + } + const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start(); + const purgeOpenCode = opts?.purgeSession === true; + const warnings: string[] = []; + for (const session of activeSessions) { + try { + const result = await sm.kill(session.id, { purgeOpenCode }); + if (result.cleaned || result.alreadyTerminated) { + killedSessionIds.push(session.id); + } + } catch (err) { + recordActivityEvent({ + projectId: session.projectId ?? _projectId, + sessionId: session.id, + source: "cli", + kind: "cli.stop_session_failed", + level: "warn", + summary: `failed to kill session during ao stop`, + data: { errorMessage: err instanceof Error ? err.message : String(err) }, + }); + warnings.push( + ` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + if (killedSessionIds.length === 0) { + spinner.fail("Failed to stop any sessions"); + } else if (killedSessionIds.length < activeSessions.length) { + spinner.warn( + `Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`, + ); + } else { + spinner.succeed(`Stopped ${killedSessionIds.length} session(s)`); + } + for (const w of warnings) { + console.log(chalk.yellow(w)); + } + // Show stopped sessions grouped by project + const killedTarget = targetActive + .filter((s) => killedSessionIds.includes(s.id)) + .map((s) => s.id); + if (killedTarget.length > 0) { + console.log(chalk.green(` ${project.name}: ${killedTarget.join(", ")}`)); + } + for (const s of otherActive) { + if (!killedSessionIds.includes(s.id)) continue; + const list = otherByProject.get(s.projectId ?? "unknown") ?? []; + list.push(s.id); + otherByProject.set(s.projectId ?? "unknown", list); + } + for (const [pid, ids] of otherByProject) { + console.log(chalk.green(` ${pid}: ${ids.join(", ")}`)); + } + } else { + console.log(chalk.yellow(`No active sessions found`)); + } - const sm = await getSessionManager(config); - try { - // When no explicit project is given, list ALL sessions — ao stop - // kills the parent process which affects all projects. When a - // specific project is targeted, scope to that project only. - const stopAll = !projectArg; - const rawSessions = await sm.list(stopAll ? undefined : _projectId); - // Defensive consumer-side filter. `sm.list(projectId)` already scopes - // to the named project, but the kill loop hard-stops processes — a - // contract regression here would silently kill another project's - // work. When a project arg is given, drop anything that isn't ours. - const allSessions = stopAll - ? rawSessions - : rawSessions.filter((s) => s.projectId === _projectId); - const activeSessions = allSessions.filter((s) => !isTerminalSession(s)); - const killedSessionIds: string[] = []; - - // Separate sessions by project for display and recording - const targetActive = activeSessions.filter((s) => s.projectId === _projectId); - const otherActive = activeSessions.filter((s) => s.projectId !== _projectId); - // Group other-project sessions by projectId (used for display + recording) - const otherByProject = new Map(); - - if (activeSessions.length > 0) { - if (!projectArg && opts.yes !== true && isHumanCaller()) { - const confirmed = await promptConfirm( - `Stop AO and ${activeSessions.length} active session(s)?`, - false, + // Record stopped sessions for restore on next `ao start` + if (killedSessionIds.length > 0) { + const otherProjects: Array<{ projectId: string; sessionIds: string[] }> = []; + for (const [pid, ids] of otherByProject) { + otherProjects.push({ projectId: pid, sessionIds: ids }); + } + + const targetSessionIds = killedSessionIds.filter((id) => + targetActive.some((s) => s.id === id), ); - if (!confirmed) { - console.log(chalk.yellow("Stop cancelled.")); - return; + try { + await writeLastStop({ + stoppedAt: new Date().toISOString(), + projectId: _projectId, + sessionIds: targetSessionIds, + otherProjects: otherProjects.length > 0 ? otherProjects : undefined, + }); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_written", + level: "info", + summary: `last-stop state written with ${killedSessionIds.length} session(s)`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + }, + }); + } catch (err) { + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.last_stop_write_failed", + level: "error", + summary: `failed to write last-stop state during ao stop`, + data: { + targetSessionCount: targetSessionIds.length, + otherProjectCount: otherProjects.length, + totalKilled: killedSessionIds.length, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + console.log( + chalk.yellow( + ` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } } - const spinner = ora(`Stopping ${activeSessions.length} active session(s)`).start(); - const purgeOpenCode = opts?.purgeSession === true; - const warnings: string[] = []; - for (const session of activeSessions) { + } catch (err) { + console.log( + chalk.yellow( + ` Could not list sessions: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + } + + // Only kill the parent `ao start` process and dashboard when stopping + // everything (no project arg). When targeting a specific project, the + // parent process and dashboard serve all projects and must stay alive. + if (!projectArg) { + // Lifecycle polling runs in-process inside the `ao start` process + // (registered via `running.json`). Sending SIGTERM to that PID below + // triggers the shared shutdown handler in `lifecycle-service`, which + // stops every per-project loop. No explicit stop call needed here — + // this CLI invocation is a separate process with an empty active map. + if (running) { + // Sweep detached Windows pty-hosts BEFORE killing the parent. + // detached:true puts them outside the parent's process tree, so + // taskkill /T cannot reach them. The sweep speaks the named-pipe + // protocol so node-pty disposes ConPTY gracefully (avoids WER + // 0x800700e8). No-op on non-Windows. + await sweepWindowsPtyHostsBeforeParentKill(); + await sweepRegisteredDaemonChildren(running.pid); try { - const result = await sm.kill(session.id, { purgeOpenCode }); - if (result.cleaned || result.alreadyTerminated) { - killedSessionIds.push(session.id); - } + await killProcessTree(running.pid, "SIGTERM"); + recordActivityEvent({ + projectId: _projectId, + source: "cli", + kind: "cli.daemon_killed", + level: "info", + summary: `SIGTERM sent to parent ao start`, + data: { pid: running.pid, port: running.port }, + }); } catch (err) { recordActivityEvent({ - projectId: session.projectId ?? _projectId, - sessionId: session.id, + projectId: _projectId, source: "cli", - kind: "cli.stop_session_failed", + kind: "cli.daemon_killed", level: "warn", - summary: `failed to kill session during ao stop`, - data: { errorMessage: err instanceof Error ? err.message : String(err) }, + summary: `parent ao start was already dead`, + data: { + pid: running.pid, + errorMessage: err instanceof Error ? err.message : String(err), + }, }); - warnings.push( - ` Warning: failed to stop ${session.id}: ${err instanceof Error ? err.message : String(err)}`, - ); } - } - if (killedSessionIds.length === 0) { - spinner.fail("Failed to stop any sessions"); - } else if (killedSessionIds.length < activeSessions.length) { - spinner.warn( - `Stopped ${killedSessionIds.length}/${activeSessions.length} session(s)`, - ); + await unregister(); } else { - spinner.succeed(`Stopped ${killedSessionIds.length} session(s)`); + await sweepRegisteredDaemonChildren(); } - for (const w of warnings) { - console.log(chalk.yellow(w)); - } - // Show stopped sessions grouped by project - const killedTarget = targetActive - .filter((s) => killedSessionIds.includes(s.id)) - .map((s) => s.id); - if (killedTarget.length > 0) { - console.log(chalk.green(` ${project.name}: ${killedTarget.join(", ")}`)); - } - for (const s of otherActive) { - if (!killedSessionIds.includes(s.id)) continue; - const list = otherByProject.get(s.projectId ?? "unknown") ?? []; - list.push(s.id); - otherByProject.set(s.projectId ?? "unknown", list); - } - for (const [pid, ids] of otherByProject) { - console.log(chalk.green(` ${pid}: ${ids.join(", ")}`)); - } - } else { - console.log(chalk.yellow(`No active sessions found`)); + await stopDashboard(running?.port ?? port); } + // Targeted stop deliberately does NOT edit `running.json` from this + // child CLI process. The long-lived parent supervises lifecycle + // workers and will remove the project from `running.projects` after + // it observes that the last session became terminal. - // Record stopped sessions for restore on next `ao start` - if (killedSessionIds.length > 0) { - const otherProjects: Array<{ projectId: string; sessionIds: string[] }> = []; - for (const [pid, ids] of otherByProject) { - otherProjects.push({ projectId: pid, sessionIds: ids }); - } - - const targetSessionIds = killedSessionIds.filter((id) => - targetActive.some((s) => s.id === id), - ); - try { - await writeLastStop({ - stoppedAt: new Date().toISOString(), - projectId: _projectId, - sessionIds: targetSessionIds, - otherProjects: otherProjects.length > 0 ? otherProjects : undefined, - }); - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.last_stop_written", - level: "info", - summary: `last-stop state written with ${killedSessionIds.length} session(s)`, - data: { - targetSessionCount: targetSessionIds.length, - otherProjectCount: otherProjects.length, - totalKilled: killedSessionIds.length, - }, - }); - } catch (err) { - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.last_stop_write_failed", - level: "error", - summary: `failed to write last-stop state during ao stop`, - data: { - targetSessionCount: targetSessionIds.length, - otherProjectCount: otherProjects.length, - totalKilled: killedSessionIds.length, - errorMessage: err instanceof Error ? err.message : String(err), - }, - }); - console.log( - chalk.yellow( - ` Could not write last-stop state: ${err instanceof Error ? err.message : String(err)}`, - ), - ); - } + if (projectArg) { + console.log(chalk.bold.green(`\n✓ Stopped sessions for ${project.name}\n`)); + } else { + console.log(chalk.bold.green("\n✓ Orchestrator stopped\n")); + console.log(chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`)); + console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`)); } } catch (err) { - console.log( - chalk.yellow( - ` Could not list sessions: ${err instanceof Error ? err.message : String(err)}`, - ), - ); - } - - // Only kill the parent `ao start` process and dashboard when stopping - // everything (no project arg). When targeting a specific project, the - // parent process and dashboard serve all projects and must stay alive. - if (!projectArg) { - // Lifecycle polling runs in-process inside the `ao start` process - // (registered via `running.json`). Sending SIGTERM to that PID below - // triggers the shared shutdown handler in `lifecycle-service`, which - // stops every per-project loop. No explicit stop call needed here — - // this CLI invocation is a separate process with an empty active map. - if (running) { - // Sweep detached Windows pty-hosts BEFORE killing the parent. - // detached:true puts them outside the parent's process tree, so - // taskkill /T cannot reach them. The sweep speaks the named-pipe - // protocol so node-pty disposes ConPTY gracefully (avoids WER - // 0x800700e8). No-op on non-Windows. - await sweepWindowsPtyHostsBeforeParentKill(); - await sweepRegisteredDaemonChildren(running.pid); - try { - await killProcessTree(running.pid, "SIGTERM"); - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.daemon_killed", - level: "info", - summary: `SIGTERM sent to parent ao start`, - data: { pid: running.pid, port: running.port }, - }); - } catch (err) { - recordActivityEvent({ - projectId: _projectId, - source: "cli", - kind: "cli.daemon_killed", - level: "warn", - summary: `parent ao start was already dead`, - data: { - pid: running.pid, - errorMessage: err instanceof Error ? err.message : String(err), - }, - }); - } - await unregister(); + recordActivityEvent({ + source: "cli", + kind: "cli.stop_failed", + level: "error", + summary: `ao stop action failed`, + data: { + projectArg: projectArg ?? null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + if (err instanceof Error) { + console.error(chalk.red("\nError:"), err.message); } else { - await sweepRegisteredDaemonChildren(); + console.error(chalk.red("\nError:"), String(err)); } - await stopDashboard(running?.port ?? port); - } - // Targeted stop deliberately does NOT edit `running.json` from this - // child CLI process. The long-lived parent supervises lifecycle - // workers and will remove the project from `running.projects` after - // it observes that the last session became terminal. - - if (projectArg) { - console.log(chalk.bold.green(`\n✓ Stopped sessions for ${project.name}\n`)); - } else { - console.log(chalk.bold.green("\n✓ Orchestrator stopped\n")); - console.log(chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`)); - console.log(chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`)); - } - } catch (err) { - recordActivityEvent({ - source: "cli", - kind: "cli.stop_failed", - level: "error", - summary: `ao stop action failed`, - data: { - projectArg: projectArg ?? null, - errorMessage: err instanceof Error ? err.message : String(err), - }, - }); - if (err instanceof Error) { - console.error(chalk.red("\nError:"), err.message); - } else { - console.error(chalk.red("\nError:"), String(err)); + process.exit(1); } - process.exit(1); - } - }); + }, + ); } diff --git a/packages/cli/src/lib/dashboard-setup.ts b/packages/cli/src/lib/dashboard-setup.ts index 49b8de230e..1b32d1ea50 100644 --- a/packages/cli/src/lib/dashboard-setup.ts +++ b/packages/cli/src/lib/dashboard-setup.ts @@ -228,8 +228,7 @@ function resolveNonInteractiveSetup( ? parseLimit(existingDashboard["limit"]) : DEFAULT_DASHBOARD_NOTIFICATION_LIMIT; const routingPreset = - resolveDashboardRoutingPreset(opts.routingPreset) ?? - (opts.refresh ? undefined : "urgent-action"); + resolveDashboardRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all"); return { limit, routingPreset }; } diff --git a/packages/cli/src/lib/desktop-setup.ts b/packages/cli/src/lib/desktop-setup.ts index 7b2d2e9224..c0274acb8a 100644 --- a/packages/cli/src/lib/desktop-setup.ts +++ b/packages/cli/src/lib/desktop-setup.ts @@ -211,7 +211,9 @@ function printStatus(): void { console.log(` config backend: ${configBackend ?? "not configured"}`); console.log(` dashboardUrl: ${configDashboardUrl ?? "not configured"}`); console.log(` config appPath: ${configAppPath ?? "not configured"}`); - console.log(` terminal-notifier: ${commandExists("terminal-notifier") ? "available" : "missing"}`); + console.log( + ` terminal-notifier: ${commandExists("terminal-notifier") ? "available" : "missing"}`, + ); console.log(` osascript: ${commandExists("osascript") ? "available" : "missing"}`); console.log(` installed: ${installed ? "yes" : "no"}`); console.log(` app: ${appPath}`); @@ -382,7 +384,10 @@ async function chooseDesktopBackend( options: [ { value: "ao-app", - label: existingBackend === "ao-app" ? "AO Notifier.app (current)" : "AO Notifier.app (recommended)", + label: + existingBackend === "ao-app" + ? "AO Notifier.app (current)" + : "AO Notifier.app (recommended)", hint: "Native macOS app with actions and AO-specific behavior", }, { @@ -392,7 +397,10 @@ async function chooseDesktopBackend( }, { value: "terminal-notifier", - label: existingBackend === "terminal-notifier" ? "terminal-notifier (current)" : "terminal-notifier", + label: + existingBackend === "terminal-notifier" + ? "terminal-notifier (current)" + : "terminal-notifier", hint: "Requires Homebrew package; supports click-to-open", }, { @@ -490,7 +498,7 @@ async function resolveDesktopSetup( (nonInteractive || !context.configPath ? opts.refresh ? undefined - : "all" + : "urgent-only" : await promptNotifierRoutingPreset( await import("@clack/prompts"), context.rawConfig, @@ -590,6 +598,21 @@ function sendBackendSetupNotification( } } +export async function installAoNotifierAppForStartup(): Promise { + await prepareDesktopBackend( + { + backend: "ao-app", + appPath: getInstalledNotifierAppPath(), + shouldWriteAppPath: false, + shouldSendTest: false, + refresh: true, + routingPreset: "urgent-only", + }, + false, + true, + ); +} + async function wireDesktopConfig( configPath: string | undefined, force: boolean, @@ -610,11 +633,7 @@ async function wireDesktopConfig( if ( !conflictAlreadyChecked && - !(await shouldReplaceConflictingDesktop( - existingDesktop["plugin"], - force, - nonInteractive, - )) + !(await shouldReplaceConflictingDesktop(existingDesktop["plugin"], force, nonInteractive)) ) { return false; } diff --git a/packages/cli/src/lib/startup-notifier-defaults.ts b/packages/cli/src/lib/startup-notifier-defaults.ts new file mode 100644 index 0000000000..291f0b6d9b --- /dev/null +++ b/packages/cli/src/lib/startup-notifier-defaults.ts @@ -0,0 +1,271 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { DEFAULT_DASHBOARD_NOTIFICATION_LIMIT } from "@aoagents/ao-core"; +import { parseDocument } from "yaml"; +import { + NOTIFICATION_PRIORITIES, + asStringArray, + type NotificationPriority, +} from "./notifier-routing.js"; + +type StartupDesktopMode = "enable" | "disable-default" | "preserve"; + +export interface StartupNotifierDefaultsOptions { + configPath: string; + dashboardUrl: string; + desktopMode?: StartupDesktopMode; +} + +interface StartupNotifierConfig { + notifiers: Record>; + notificationRouting: Record; +} + +const MANUAL_OPT_IN_NOTIFIER_NAMES = new Set([ + "composio", + "composio-slack", + "composio-discord", + "composio-discord-bot", + "composio-mail", + "discord", + "openclaw", + "slack", + "webhook", +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function unique(values: string[]): string[] { + return [...new Set(values)]; +} + +function arraysEqual(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function hasNotifierConfig(notifiers: Record, notifierName: string): boolean { + return isRecord(notifiers[notifierName]); +} + +function isImplicitManualOptInNotifier( + notifiers: Record, + notifierName: string, +): boolean { + return ( + MANUAL_OPT_IN_NOTIFIER_NAMES.has(notifierName) && !hasNotifierConfig(notifiers, notifierName) + ); +} + +function sanitizeNotifierReferences( + notifiers: Record, + references: unknown, +): string[] { + return unique( + asStringArray(references).filter( + (notifierName) => !isImplicitManualOptInNotifier(notifiers, notifierName), + ), + ); +} + +function configuredPlugin( + notifiers: Record, + notifierName: string, +): string | undefined { + const entry = notifiers[notifierName]; + if (!isRecord(entry)) return undefined; + const plugin = entry["plugin"]; + return typeof plugin === "string" && plugin.length > 0 ? plugin : undefined; +} + +function canManageNotifier( + notifiers: Record, + notifierName: "dashboard" | "desktop", +): boolean { + const plugin = configuredPlugin(notifiers, notifierName); + return plugin === undefined || plugin === notifierName; +} + +function ensureDashboardNotifier(notifiers: Record): boolean { + if (!canManageNotifier(notifiers, "dashboard")) return false; + + const existing = isRecord(notifiers["dashboard"]) ? notifiers["dashboard"] : {}; + const next = { + ...existing, + plugin: "dashboard", + limit: + typeof existing["limit"] === "number" + ? existing["limit"] + : DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + }; + const changed = JSON.stringify(existing) !== JSON.stringify(next); + notifiers["dashboard"] = next; + return changed; +} + +function ensureDesktopNotifier(notifiers: Record, dashboardUrl: string): boolean { + if (!canManageNotifier(notifiers, "desktop")) return false; + + const existing = isRecord(notifiers["desktop"]) ? notifiers["desktop"] : {}; + const existingDashboardUrl = + typeof existing["dashboardUrl"] === "string" && existing["dashboardUrl"].length > 0 + ? existing["dashboardUrl"] + : undefined; + const next = { + ...existing, + plugin: "desktop", + backend: typeof existing["backend"] === "string" ? existing["backend"] : "ao-app", + dashboardUrl: + existingDashboardUrl && !isLocalhostDashboardUrl(existingDashboardUrl) + ? existingDashboardUrl + : dashboardUrl, + }; + const changed = JSON.stringify(existing) !== JSON.stringify(next); + notifiers["desktop"] = next; + return changed; +} + +function isLocalhostDashboardUrl(value: string): boolean { + return /^http:\/\/localhost:\d+$/.test(value); +} + +function isDefaultDesktopNotifier(entry: unknown, dashboardUrl: string): boolean { + if (!isRecord(entry)) return false; + const allowedKeys = new Set(["plugin", "backend", "dashboardUrl"]); + const plugin = entry["plugin"]; + const backend = entry["backend"]; + const configuredDashboardUrl = entry["dashboardUrl"]; + return ( + plugin === "desktop" && + (backend === undefined || backend === "ao-app") && + (configuredDashboardUrl === undefined || + configuredDashboardUrl === dashboardUrl || + (typeof configuredDashboardUrl === "string" && + isLocalhostDashboardUrl(configuredDashboardUrl))) && + Object.keys(entry).every((key) => allowedKeys.has(key)) + ); +} + +function disableDefaultDesktopNotifier( + notifiers: Record, + dashboardUrl: string, +): boolean { + if (!isDefaultDesktopNotifier(notifiers["desktop"], dashboardUrl)) return false; + delete notifiers["desktop"]; + return true; +} + +function hasDesktopRouting(routing: Record): boolean { + return NOTIFICATION_PRIORITIES.some((priority) => + asStringArray(routing[priority]).includes("desktop"), + ); +} + +export function createStartupNotifierConfig(port = 3000): StartupNotifierConfig { + const dashboardUrl = `http://localhost:${port}`; + return { + notifiers: { + desktop: { + plugin: "desktop", + backend: "ao-app", + dashboardUrl, + }, + dashboard: { + plugin: "dashboard", + limit: DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + }, + }, + notificationRouting: { + urgent: ["desktop", "dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }, + }; +} + +export function ensureStartupNotifierDefaults(options: StartupNotifierDefaultsOptions): boolean { + if (!existsSync(options.configPath)) return false; + + const rawYaml = readFileSync(options.configPath, "utf-8"); + const doc = parseDocument(rawYaml); + const rawConfig = (doc.toJS() as Record | null) ?? {}; + const desktopMode = options.desktopMode ?? "enable"; + let changed = false; + + const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {}; + const desktopWasConfigured = configuredPlugin(notifiers, "desktop") === "desktop"; + const routing = isRecord(rawConfig["notificationRouting"]) + ? rawConfig["notificationRouting"] + : {}; + const shouldManageDesktopRouting = + desktopMode === "enable" && + canManageNotifier(notifiers, "desktop") && + (!desktopWasConfigured || !hasDesktopRouting(routing)); + + changed = ensureDashboardNotifier(notifiers) || changed; + if (desktopMode === "enable") { + changed = ensureDesktopNotifier(notifiers, options.dashboardUrl) || changed; + } else if (desktopMode === "disable-default") { + changed = disableDefaultDesktopNotifier(notifiers, options.dashboardUrl) || changed; + } + rawConfig["notifiers"] = notifiers; + + const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {}; + const sanitizedDefaultNotifiers = sanitizeNotifierReferences( + notifiers, + defaults["notifiers"], + ).filter((notifierName) => notifierName !== "dashboard" && notifierName !== "desktop"); + if (!arraysEqual(asStringArray(defaults["notifiers"]), sanitizedDefaultNotifiers)) { + defaults["notifiers"] = sanitizedDefaultNotifiers; + changed = true; + } + rawConfig["defaults"] = defaults; + + const dashboardConfigured = configuredPlugin(notifiers, "dashboard") === "dashboard"; + const desktopConfigured = configuredPlugin(notifiers, "desktop") === "desktop"; + const shouldRemoveDefaultDesktopRouting = desktopMode === "disable-default" && !desktopConfigured; + + for (const priority of NOTIFICATION_PRIORITIES) { + const hasExplicitRoute = hasOwn(routing, priority); + const existingRoute = sanitizeNotifierReferences( + notifiers, + hasExplicitRoute ? routing[priority] : defaults["notifiers"], + ); + const previousRoute = hasExplicitRoute ? asStringArray(routing[priority]) : existingRoute; + let nextRoute = existingRoute; + + if (shouldRemoveDefaultDesktopRouting) { + nextRoute = nextRoute.filter((notifierName) => notifierName !== "desktop"); + } else if (shouldManageDesktopRouting && desktopConfigured) { + nextRoute = nextRoute.filter((notifierName) => notifierName !== "desktop"); + if (priority === "urgent") nextRoute = [...nextRoute, "desktop"]; + } + + if (dashboardConfigured) { + nextRoute = [ + ...nextRoute.filter((notifierName) => notifierName !== "dashboard"), + "dashboard", + ]; + } + + nextRoute = unique(nextRoute); + if (!arraysEqual(previousRoute, nextRoute)) { + routing[priority] = nextRoute; + changed = true; + } + } + rawConfig["notificationRouting"] = routing; + + if (!changed) return false; + + doc.setIn(["notifiers"], rawConfig["notifiers"]); + doc.setIn(["defaults"], rawConfig["defaults"]); + doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]); + writeFileSync(options.configPath, doc.toString({ indent: 2 })); + return true; +} diff --git a/packages/core/src/__tests__/global-config.test.ts b/packages/core/src/__tests__/global-config.test.ts index 2e7c516282..d535921575 100644 --- a/packages/core/src/__tests__/global-config.test.ts +++ b/packages/core/src/__tests__/global-config.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { + createDefaultGlobalConfig, generateExternalId, loadGlobalConfig, migrateToGlobalConfig, @@ -80,6 +81,30 @@ describe("global-config storage identity", () => { expect(config?.projects[projectId]).not.toHaveProperty("runtime"); }); + it("creates fresh global configs without Composio and with startup notifier defaults", () => { + const config = createDefaultGlobalConfig(); + + expect(config.defaults.notifiers).toEqual([]); + expect(config.notifiers).toEqual({ + desktop: { + plugin: "desktop", + backend: "ao-app", + dashboardUrl: "http://localhost:3000", + }, + dashboard: { + plugin: "dashboard", + limit: 50, + }, + }); + expect(config.notificationRouting).toEqual({ + urgent: ["desktop", "dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }); + expect(JSON.stringify(config)).not.toContain("composio"); + }); + it("rejects registration when another project already owns the generated session prefix", () => { const repoPath = join(tempRoot, "apps", "web"); mkdirSync(join(repoPath, ".git"), { recursive: true }); @@ -379,7 +404,9 @@ describe("global-config storage identity", () => { ].join("\n"), ); - expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({ + expect( + resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath), + ).toMatchObject({ resolveError: expect.stringContaining("wrapped projects: format"), }); @@ -393,7 +420,9 @@ describe("global-config storage identity", () => { orchestrator: { agent: "codex" }, worker: { agent: "opencode" }, }); - expect(resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath)).toMatchObject({ + expect( + resolveProjectIdentity(projectId, loadGlobalConfig(configPath)!, configPath), + ).toMatchObject({ agent: "codex", runtime: "tmux", workspace: "worktree", diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 880e96c185..4b6e12b536 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -387,6 +387,35 @@ describe("loadBuiltins", () => { }); }); + it("does not implicitly register manual opt-in built-in notifiers from routing alone", async () => { + const registry = createPluginRegistry(); + const fakeComposio = makePlugin("notifier", "composio"); + const cfg = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + defaults: { + runtime: "tmux", + agent: "codex", + workspace: "worktree", + notifiers: ["composio"], + }, + notificationRouting: { + urgent: ["composio"], + action: [], + warning: [], + info: [], + }, + notifiers: {}, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@aoagents/ao-plugin-notifier-composio") return fakeComposio; + throw new Error(`Not found: ${pkg}`); + }); + + expect(fakeComposio.create).not.toHaveBeenCalled(); + expect(registry.get("notifier", "composio")).toBeNull(); + }); + it("does not create an implicit notifier registration over a conflicting explicit entry", async () => { const registry = createPluginRegistry(); const fakeDashboard = makePlugin("notifier", "dashboard"); diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index f5840cc39c..ff67735089 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -12,6 +12,7 @@ import { generateSessionPrefix } from "./paths.js"; import { normalizeOriginUrl } from "./storage-key.js"; import { getDefaultRuntime } from "./platform.js"; import { recordActivityEvent } from "./activity-events.js"; +import { DEFAULT_DASHBOARD_NOTIFICATION_LIMIT } from "./dashboard-notifications.js"; function globalConfigLockPath(configPath: string): string { return `${configPath}.lock`; @@ -68,6 +69,32 @@ export interface RegisterProjectOptions { allowStorageKeyReuse?: boolean; } +function createDefaultNotifierConfigs(): Record< + string, + { plugin: string } & Record +> { + return { + desktop: { + plugin: "desktop", + backend: "ao-app", + dashboardUrl: "http://localhost:3000", + }, + dashboard: { + plugin: "dashboard", + limit: DEFAULT_DASHBOARD_NOTIFICATION_LIMIT, + }, + }; +} + +function createDefaultNotificationRouting(): Record { + return { + urgent: ["desktop", "dashboard"], + action: ["dashboard"], + warning: ["dashboard"], + info: ["dashboard"], + }; +} + // ============================================================================= // GLOBAL CONFIG PATH (XDG-aware) // ============================================================================= @@ -219,7 +246,7 @@ export const GlobalConfigSchema = z runtime: z.string().default(() => getDefaultRuntime()), agent: z.string().default("claude-code"), workspace: z.string().default("worktree"), - notifiers: z.array(z.string()).default(["composio", "desktop"]), + notifiers: z.array(z.string()).default([]), orchestrator: z.object({ agent: z.string().optional() }).optional(), worker: z.object({ agent: z.string().optional() }).optional(), }) @@ -229,14 +256,13 @@ export const GlobalConfigSchema = z /** Optional explicit project ordering for sidebar / portfolio display. */ projectOrder: z.array(z.string()).optional(), /** Notification channel configurations. */ - notifiers: z.record(z.object({ plugin: z.string() }).passthrough()).default({}), + notifiers: z + .record(z.object({ plugin: z.string() }).passthrough()) + .default(() => createDefaultNotifierConfigs()), /** Maps priority levels to notifier channel IDs. */ - notificationRouting: z.record(z.array(z.string())).default({ - urgent: ["desktop", "composio"], - action: ["desktop", "composio"], - warning: ["composio"], - info: ["composio"], - }), + notificationRouting: z + .record(z.array(z.string())) + .default(() => createDefaultNotificationRouting()), /** Reaction rules (default reactions merged at load time). */ reactions: z.record(z.object({}).passthrough()).default({}), }) @@ -480,7 +506,9 @@ export function writeLocalProjectConfig( } function isRecord(value: unknown): value is Record { - return value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value); + return ( + value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value) + ); } function mergeRoleBehavior( @@ -806,9 +834,10 @@ export function registerProjectInGlobalConfig( } } - const repoIdentity = existing?.repo - ?? normalizeRepoIdentity(originUrl) - ?? (localConfig?.repo ? normalizeLegacyRepoValue(localConfig.repo) : undefined); + const repoIdentity = + existing?.repo ?? + normalizeRepoIdentity(originUrl) ?? + (localConfig?.repo ? normalizeLegacyRepoValue(localConfig.repo) : undefined); const defaultBranch = existing?.defaultBranch ?? localConfig?.defaultBranch ?? "main"; const requestedSessionPrefix = existing?.sessionPrefix ?? @@ -1185,16 +1214,11 @@ export function createDefaultGlobalConfig(): GlobalConfig { runtime: getDefaultRuntime(), agent: "claude-code", workspace: "worktree", - notifiers: ["composio", "desktop"], + notifiers: [], }, projects: {}, - notifiers: {}, - notificationRouting: { - urgent: ["desktop", "composio"], - action: ["desktop", "composio"], - warning: ["composio"], - info: ["composio"], - }, + notifiers: createDefaultNotifierConfigs(), + notificationRouting: createDefaultNotificationRouting(), reactions: {}, }; } diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 304d8a893c..c4f727fa2e 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -94,6 +94,10 @@ function hasExplicitConflictingNotifierEntry( ); } +function canImplicitlyRegisterNotifier(pluginName: string, isExternalLoad: boolean): boolean { + return isExternalLoad || pluginName === "dashboard" || pluginName === "desktop"; +} + function collectNotifierRegistrations( pluginName: string, config: OrchestratorConfig, @@ -129,6 +133,7 @@ function collectNotifierRegistrations( if ( orderedMatches.size === 0 && isReferencedByName && + canImplicitlyRegisterNotifier(pluginName, isExternalLoad) && !hasExplicitConflictingNotifierEntry(pluginName, config) ) { orderedMatches.set(pluginName, {}); @@ -439,7 +444,9 @@ export function createPluginRegistry(): PluginRegistry { if (registrations.length === 0) { if (hasExplicitConflictingNotifierEntry(manifest.name, config)) return; - registerInstance(manifest.slot, manifest.name, manifest, plugin.create(undefined)); + if (isExternalLoad) { + registerInstance(manifest.slot, manifest.name, manifest, plugin.create(undefined)); + } return; } diff --git a/packages/integration-tests/src/notifier-desktop.integration.test.ts b/packages/integration-tests/src/notifier-desktop.integration.test.ts index 52fe1b075a..cd9463f38b 100644 --- a/packages/integration-tests/src/notifier-desktop.integration.test.ts +++ b/packages/integration-tests/src/notifier-desktop.integration.test.ts @@ -11,7 +11,9 @@ import { makeEvent } from "./helpers/event-factory.js"; vi.mock("node:child_process", () => ({ execFile: vi.fn(), execFileSync: vi.fn(() => { - throw new Error("not found"); + const error = new Error("not found") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; }), })); diff --git a/packages/plugins/notifier-desktop/src/index.test.ts b/packages/plugins/notifier-desktop/src/index.test.ts index 1f7755c413..21cba37761 100644 --- a/packages/plugins/notifier-desktop/src/index.test.ts +++ b/packages/plugins/notifier-desktop/src/index.test.ts @@ -483,6 +483,18 @@ describe("notifier-desktop", () => { expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); }); + it("keeps action labels on non-macOS even when backend is ao-app", async () => { + mockPlatform.mockReturnValue("linux"); + setProcessPlatform("linux"); + const notifier = create({ backend: "ao-app", dashboardUrl: "http://localhost:3000" }); + const actions: NotifyAction[] = [{ label: "Open PR", url: "https://example.com/pr/1" }]; + await notifier.notifyWithActions!(makeEvent(), actions); + + expect(mockExecFile.mock.calls[0][0]).toBe("notify-send"); + const args = mockExecFile.mock.calls[0][1] as string[]; + expect(args.join("\n")).toContain("Open PR"); + }); + it("uses terminal-notifier for notifyWithActions too", async () => { const notifier = create({ dashboardUrl: "http://localhost:3000" }); const actions: NotifyAction[] = [{ label: "View", url: "https://example.com" }]; diff --git a/packages/plugins/notifier-desktop/src/index.ts b/packages/plugins/notifier-desktop/src/index.ts index cf25ac307a..afce79928f 100644 --- a/packages/plugins/notifier-desktop/src/index.ts +++ b/packages/plugins/notifier-desktop/src/index.ts @@ -388,6 +388,10 @@ function detectTerminalNotifier(): boolean { } } +function usesAoNotifierNativeActions(backend: DesktopBackend, appPath: string): boolean { + return isMac() && (backend === "ao-app" || (backend === "auto" && detectAoNotifierApp(appPath))); +} + /** * Send a desktop notification using terminal-notifier / osascript (macOS) or * notify-send (Linux). Falls back gracefully if neither is available. @@ -517,9 +521,7 @@ function sendNotification( // Don't crash the lifecycle on toast failures — log and resolve. // Common causes: stripped-down Windows SKU without WinRT, locked // group policy, or the user disabled toast notifications. - console.warn( - `[notifier-desktop] Windows toast failed: ${(err as Error).message}`, - ); + console.warn(`[notifier-desktop] Windows toast failed: ${(err as Error).message}`); } resolve(); }, @@ -570,10 +572,9 @@ export function create(config?: Record): Notifier { async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { const nativeActions = nativeActionPayloads(actions, dashboardUrl); const content = formatContent(event, actions, { - hiddenActionIndexes: - backend === "ao-app" || (backend === "auto" && detectAoNotifierApp(appPath)) - ? nativeActionIndexes(actions, dashboardUrl) - : undefined, + hiddenActionIndexes: usesAoNotifierNativeActions(backend, appPath) + ? nativeActionIndexes(actions, dashboardUrl) + : undefined, }); const fallbackContent = formatContent(event, actions); const sound = shouldPlaySound(event.priority, soundEnabled);