diff --git a/README.md b/README.md index f03e354d0..fd4d8c3b9 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ The orchestrator agent uses the [AO CLI](docs/CLI.md) internally to manage sessi ```yaml # agent-orchestrator.yaml +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json # Runtime data is auto-derived under ~/.agent-orchestrator/{hash}-{projectId}/ port: 3000 @@ -162,6 +163,8 @@ reactions: CI fails → agent gets the logs and fixes it. Reviewer requests changes → agent addresses them. PR approved with green CI → you get a notification to merge. +Keep the `$schema` line so editors can autocomplete and validate against [`schema/config.schema.json`](schema/config.schema.json). + See [`agent-orchestrator.yaml.example`](agent-orchestrator.yaml.example) for the full reference, or run `ao config-help` for the complete schema. ## Remote Access @@ -172,6 +175,7 @@ AO keeps your Mac awake while running, so you can access the dashboard remotely ```yaml # agent-orchestrator.yaml +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json power: preventIdleSleep: true # Default on macOS, no-op on Linux ``` diff --git a/agent-orchestrator.yaml.example b/agent-orchestrator.yaml.example index dd9f385ea..58157e2b5 100644 --- a/agent-orchestrator.yaml.example +++ b/agent-orchestrator.yaml.example @@ -1,5 +1,6 @@ # Agent Orchestrator Configuration # Copy to agent-orchestrator.yaml and customize. +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json # Runtime data directories are auto-derived from this config location under: # ~/.agent-orchestrator/{hash}-{projectId}/ diff --git a/examples/auto-merge.yaml b/examples/auto-merge.yaml index 28b2d37f0..8d68e501d 100644 --- a/examples/auto-merge.yaml +++ b/examples/auto-merge.yaml @@ -1,8 +1,6 @@ # Aggressive automation with auto-merge # Automatically merges approved PRs with passing CI - -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json projects: my-app: diff --git a/examples/codex-integration.yaml b/examples/codex-integration.yaml index a25964f91..01e739707 100644 --- a/examples/codex-integration.yaml +++ b/examples/codex-integration.yaml @@ -1,8 +1,6 @@ # Using Codex instead of Claude Code # Demonstrates using a different AI agent - -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json defaults: agent: codex # Use Codex instead of Claude Code diff --git a/examples/linear-team.yaml b/examples/linear-team.yaml index 2c141a491..9453d77b3 100644 --- a/examples/linear-team.yaml +++ b/examples/linear-team.yaml @@ -1,8 +1,6 @@ # Linear integration with custom team # Requires LINEAR_API_KEY environment variable - -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json projects: my-app: diff --git a/examples/multi-project.yaml b/examples/multi-project.yaml index 6e731612b..b1fb244ba 100644 --- a/examples/multi-project.yaml +++ b/examples/multi-project.yaml @@ -1,8 +1,6 @@ # Managing multiple projects with different trackers # Shows how to configure multiple repos with different settings - -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json defaults: runtime: tmux diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml index 1730e63d6..fc5680d12 100644 --- a/examples/simple-github.yaml +++ b/examples/simple-github.yaml @@ -1,8 +1,6 @@ # Minimal setup for a single GitHub repo with GitHub Issues # Perfect for getting started quickly - -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json projects: my-app: diff --git a/packages/cli/__tests__/commands/plugin.test.ts b/packages/cli/__tests__/commands/plugin.test.ts index 7573772e7..19fd22c90 100644 --- a/packages/cli/__tests__/commands/plugin.test.ts +++ b/packages/cli/__tests__/commands/plugin.test.ts @@ -29,14 +29,15 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => { return { ...actual, findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + isCanonicalGlobalConfigPath: (configPath: string | undefined) => + configPath?.endsWith("global-config.yaml") ?? false, }; }); vi.mock("../../src/lib/plugin-store.js", () => ({ getLatestPublishedPackageVersion: (...args: unknown[]) => mockGetLatestPublishedPackageVersion(...args), - importPluginModuleFromSource: (...args: unknown[]) => - mockImportPluginModuleFromSource(...args), + importPluginModuleFromSource: (...args: unknown[]) => mockImportPluginModuleFromSource(...args), installPackageIntoStore: (...args: unknown[]) => mockInstallPackageIntoStore(...args), readInstalledPackageVersion: (...args: unknown[]) => mockReadInstalledPackageVersion(...args), uninstallPackageFromStore: (...args: unknown[]) => mockUninstallPackageFromStore(...args), @@ -118,11 +119,13 @@ describe("plugin command", () => { return storeVersions.get(packageName) ?? null; }); - mockInstallPackageIntoStore.mockImplementation(async (packageName: string, version?: string) => { - const resolved = version ?? "0.0.1"; - storeVersions.set(packageName, resolved); - return resolved; - }); + mockInstallPackageIntoStore.mockImplementation( + async (packageName: string, version?: string) => { + const resolved = version ?? "0.0.1"; + storeVersions.set(packageName, resolved); + return resolved; + }, + ); mockUninstallPackageFromStore.mockImplementation(async (packageName: string) => { return storeVersions.delete(packageName); @@ -249,6 +252,28 @@ describe("plugin command", () => { expect(mockRunSetupAction).toHaveBeenCalled(); }); + it("does not stamp wrapped config schema onto the canonical global config", async () => { + configPath = join(tempDir, "global-config.yaml"); + writeConfig(configPath); + mockFindConfigFile.mockReturnValue(configPath); + + const program = createProgram(); + + await program.parseAsync(["node", "test", "plugin", "install", "notifier-openclaw"]); + + const writtenYaml = readFileSync(configPath, "utf-8"); + expect(writtenYaml).not.toContain("$schema:"); + expect(parseYaml(writtenYaml)).toMatchObject({ + plugins: [ + { + name: "openclaw", + source: "registry", + package: OPENCLAW_PACKAGE, + }, + ], + }); + }); + it("updates an npm plugin and persists the resolved store version", async () => { writeConfig(configPath, [ "plugins:", @@ -291,9 +316,9 @@ describe("plugin command", () => { const program = createProgram(); - await expect( - program.parseAsync(["node", "test", "plugin", "update", "goose"]), - ).rejects.toThrow("Failed to update plugin"); + await expect(program.parseAsync(["node", "test", "plugin", "update", "goose"])).rejects.toThrow( + "Failed to update plugin", + ); const parsed = parseYaml(readFileSync(configPath, "utf-8")) as { plugins?: Array>; diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index 6629c6702..7914a00ac 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -26,7 +26,11 @@ const { mockProbeGateway, mockValidateToken, mockDetectOpenClawInstallation } = })); vi.mock("@aoagents/ao-core", () => ({ + CONFIG_SCHEMA_URL: + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", findConfigFile: (...args: unknown[]) => mockFindConfigFile(...args), + isCanonicalGlobalConfigPath: (configPath: string | undefined) => + configPath === join(homedir(), ".agent-orchestrator", "config.yaml"), })); vi.mock("node:fs", async (importOriginal) => { @@ -235,6 +239,27 @@ describe("setup openclaw command", () => { expect(writtenYaml).not.toContain("desktop"); }); + it("does not stamp wrapped config schema onto the canonical global config", async () => { + mockFindConfigFile.mockReturnValue(join(homedir(), ".agent-orchestrator", "config.yaml")); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + expect(writtenYaml).not.toContain("$schema:"); + expect(writtenYaml).toContain("openclaw"); + }); + it("does not add desktop to defaults.notifiers when initializing notifiers", async () => { // Config with no notifiers at all mockReadFileSync.mockReturnValue(` diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index 040c87e6b..061233cbe 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -1695,7 +1695,13 @@ describe("start command — autoCreateConfig", () => { expect(existsSync(configPath)).toBe(true); const content = readFileSync(configPath, "utf-8"); - const parsed = parseYaml(content) as { defaults?: { notifiers?: unknown[] } }; + const parsed = parseYaml(content) as { + "$schema"?: string; + defaults?: { notifiers?: unknown[] }; + }; + expect(parsed["$schema"]).toBe( + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + ); expect(parsed.defaults?.notifiers).toEqual([]); }); }); diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts index ddb53df9f..fa2adcc1e 100644 --- a/packages/cli/src/commands/plugin.ts +++ b/packages/cli/src/commands/plugin.ts @@ -2,8 +2,10 @@ import { readFileSync, renameSync, writeFileSync } from "node:fs"; import chalk from "chalk"; import type { Command } from "commander"; import { + withConfigSchema, createPluginRegistry, findConfigFile, + isCanonicalGlobalConfigPath, loadConfig, type PluginSlot, type InstalledPluginConfig, @@ -65,7 +67,9 @@ function writePluginsConfig(configPath: string, plugins: InstalledPluginConfig[] } else { rawConfig.plugins = plugins; } - doc.contents = doc.createNode(rawConfig) as typeof doc.contents; + doc.contents = doc.createNode( + isCanonicalGlobalConfigPath(configPath) ? rawConfig : withConfigSchema(rawConfig), + ) as typeof doc.contents; const rendered = doc.toString({ indent: 2 }); const tempPath = `${configPath}.tmp.${process.pid}.${Date.now()}`; writeFileSync(tempPath, rendered, "utf-8"); @@ -140,7 +144,9 @@ async function installOrVerifyPlugin( async function resolveTargetVersion(plugin: InstalledPluginConfig): Promise { if (!plugin.package) { - throw new Error(`Plugin ${plugin.name} does not have a package-backed source and cannot be updated.`); + throw new Error( + `Plugin ${plugin.name} does not have a package-backed source and cannot be updated.`, + ); } const marketplacePlugin = @@ -158,7 +164,9 @@ async function updateManagedPlugin( plugin: InstalledPluginConfig, ): Promise<"updated" | "skipped"> { if (!plugin.package || plugin.source === "local") { - throw new Error(`Plugin ${plugin.name} is local-only and cannot be updated through the AO store.`); + throw new Error( + `Plugin ${plugin.name} is local-only and cannot be updated through the AO store.`, + ); } const currentVersion = readInstalledPackageVersion(plugin.package) ?? plugin.version ?? null; @@ -186,10 +194,7 @@ async function updateManagedPlugin( { ...plugin, version: installedVersion }, plugin.package, ); - writePluginsConfig( - configPath, - upsertInstalledPlugin(config.plugins ?? [], verifiedDescriptor), - ); + writePluginsConfig(configPath, upsertInstalledPlugin(config.plugins ?? [], verifiedDescriptor)); console.log( chalk.green( `Updated ${verifiedDescriptor.name} from ${currentVersion ?? "not installed"} to ${installedVersion}`, @@ -204,10 +209,9 @@ async function updateManagedPlugin( const message = err instanceof Error ? err.message : String(err); const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); - throw new Error( - `${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`, - { cause: rollbackErr }, - ); + throw new Error(`${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`, { + cause: rollbackErr, + }); } } throw err; @@ -269,7 +273,13 @@ function printPluginListFromCatalog( : installed; if (filtered.length === 0) { - console.log(chalk.dim(slotFilter ? `No installed plugins with type "${slotFilter}".` : "No plugins are installed in this config.")); + console.log( + chalk.dim( + slotFilter + ? `No installed plugins with type "${slotFilter}".` + : "No plugins are installed in this config.", + ), + ); return; } @@ -286,7 +296,13 @@ function printPluginListFromCatalog( const filteredCatalog = slotFilter ? catalog.filter((p) => p.slot === slotFilter) : catalog; if (filteredCatalog.length === 0) { - console.log(chalk.dim(slotFilter ? `No marketplace plugins with type "${slotFilter}".` : "No marketplace plugins found.")); + console.log( + chalk.dim( + slotFilter + ? `No marketplace plugins with type "${slotFilter}".` + : "No marketplace plugins found.", + ), + ); return; } @@ -315,7 +331,8 @@ export function registerPlugin(program: Command): void { if (configPath) { config = loadConfig(configPath); } - const catalog = opts.refresh === true ? await refreshMarketplaceCatalog() : loadMarketplaceCatalog(); + const catalog = + opts.refresh === true ? await refreshMarketplaceCatalog() : loadMarketplaceCatalog(); printPluginListFromCatalog(config, opts.installed === true, catalog, opts.type); }); @@ -350,7 +367,10 @@ export function registerPlugin(program: Command): void { .description("Scaffold a new AO plugin package") .argument("[directory]", "Target directory for the new plugin") .option("--name ", "Display/plugin name") - .option("--slot ", "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal") + .option( + "--slot ", + "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal", + ) .option("--description ", "Short plugin description") .option("--author ", "Package author") .option("--package-name ", "npm package name") @@ -483,8 +503,14 @@ export function registerPlugin(program: Command): void { .command("install") .description("Install a plugin into the current config") .argument("", "Marketplace id, package name, or local path") - .option("--url ", "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)") - .option("--token ", "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)") + .option( + "--url ", + "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)", + ) + .option( + "--token ", + "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)", + ) .action(async (reference: string, opts: { url?: string; token?: string }) => { const configPath = findConfigFile(); if (!configPath) { diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index bc69675bc..fa03f465d 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -14,7 +14,7 @@ import { join } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { parse as yamlParse, parseDocument } from "yaml"; -import { findConfigFile } from "@aoagents/ao-core"; +import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core"; import { probeGateway, validateToken, @@ -330,6 +330,12 @@ function writeOpenClawConfig( } // Update the document tree from the modified plain object while preserving comments + if (!isCanonicalGlobalConfigPath(configPath)) { + const currentSchema = doc.get("$schema"); + if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) { + doc.set("$schema", CONFIG_SCHEMA_URL); + } + } doc.setIn(["notifiers"], rawConfig.notifiers); doc.setIn(["defaults"], rawConfig.defaults); doc.setIn(["notificationRouting"], rawConfig.notificationRouting); @@ -498,7 +504,10 @@ export function registerSetup(program: Command): void { "--routing-preset ", "OpenClaw routing preset: urgent-only | urgent-action | all", ) - .option("--non-interactive", "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)") + .option( + "--non-interactive", + "Skip prompts — auto-detects OpenClaw if --url not provided (token auto-generated if not provided)", + ) .action(async (opts: SetupOptions) => { try { await runSetupAction(opts); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 817f92c41..357750039 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -22,13 +22,13 @@ import { generateSessionPrefix, getOrchestratorSessionId, findConfigFile, - getGlobalConfigPath, isRepoUrl, parseRepoUrl, resolveCloneTarget, isRepoAlreadyCloned, generateConfigFromUrl, configToYaml, + isCanonicalGlobalConfigPath, isOrchestratorSession, isTerminalSession, ConfigNotFoundError, @@ -66,7 +66,11 @@ import { import { preventIdleSleep } from "../lib/prevent-sleep.js"; import { isHumanCaller } from "../lib/caller-context.js"; import { detectEnvironment } from "../lib/detect-env.js"; -import { detectAgentRuntime, detectAvailableAgents, type DetectedAgent } from "../lib/detect-agent.js"; +import { + detectAgentRuntime, + detectAvailableAgents, + type DetectedAgent, +} from "../lib/detect-agent.js"; import { detectDefaultBranch } from "../lib/git-utils.js"; import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js"; import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js"; @@ -87,11 +91,6 @@ import { projectSessionUrl } from "../lib/routes.js"; // HELPERS // ============================================================================= -function isCanonicalGlobalConfigPath(configPath: string | undefined): boolean { - if (!configPath) return false; - return resolve(configPath) === resolve(getGlobalConfigPath()); -} - function readProjectBehaviorConfig(projectPath: string): LocalProjectConfig { const localConfig = loadLocalProjectConfigDetailed(projectPath); if (localConfig.kind === "loaded") { @@ -205,15 +204,9 @@ function genericInstallHints(command: string): string[] { case "npm": return ["Install Node.js/npm from https://nodejs.org/"]; case "pnpm": - return [ - "corepack enable && corepack prepare pnpm@latest --activate", - "npm install -g pnpm", - ]; + return ["corepack enable && corepack prepare pnpm@latest --activate", "npm install -g pnpm"]; case "pipx": - return [ - "python3 -m pip install --user pipx", - "python3 -m pipx ensurepath", - ]; + return ["python3 -m pip install --user pipx", "python3 -m pipx ensurepath"]; default: return []; } @@ -226,7 +219,7 @@ function genericInstallHints(command: string): string[] { */ async function promptAgentSelection(): Promise<{ orchestratorAgent: string; - workerAgent: string + workerAgent: string; } | null> { if (canPromptForInstall()) { const available = await detectAvailableAgents(); @@ -261,7 +254,11 @@ function gitInstallAttempts(): InstallAttempt[] { } if (process.platform === "linux") { return [ - { cmd: "sudo", args: ["apt-get", "install", "-y", "git"], label: "sudo apt-get install -y git" }, + { + cmd: "sudo", + args: ["apt-get", "install", "-y", "git"], + label: "sudo apt-get install -y git", + }, { cmd: "sudo", args: ["dnf", "install", "-y", "git"], label: "sudo dnf install -y git" }, ]; } @@ -280,10 +277,7 @@ function gitInstallAttempts(): InstallAttempt[] { function gitInstallHints(): string[] { if (process.platform === "darwin") return ["brew install git"]; if (process.platform === "win32") return ["winget install --id Git.Git -e --source winget"]; - return [ - "sudo apt install git # Debian/Ubuntu", - "sudo dnf install git # Fedora/RHEL", - ]; + return ["sudo apt install git # Debian/Ubuntu", "sudo dnf install git # Fedora/RHEL"]; } function ghInstallAttempts(): InstallAttempt[] { @@ -292,7 +286,11 @@ function ghInstallAttempts(): InstallAttempt[] { } if (process.platform === "linux") { return [ - { cmd: "sudo", args: ["apt-get", "install", "-y", "gh"], label: "sudo apt-get install -y gh" }, + { + cmd: "sudo", + args: ["apt-get", "install", "-y", "gh"], + label: "sudo apt-get install -y gh", + }, { cmd: "sudo", args: ["dnf", "install", "-y", "gh"], label: "sudo dnf install -y gh" }, ]; } @@ -411,18 +409,17 @@ async function promptInstallAgentRuntime(available: DetectedAgent[]): Promise 0 || !canPromptForInstall()) return available; console.log(chalk.yellow("⚠ No supported agent runtime detected.")); - console.log(chalk.dim(" You can install one now (recommended) or continue and install later.\n")); - const choice = await promptSelect( - "Choose runtime to install:", - [ - ...AGENT_INSTALL_OPTIONS.map((option) => ({ - value: option.id, - label: option.label, - hint: [option.cmd, ...option.args].join(" "), - })), - { value: "skip", label: "Skip for now" }, - ], + console.log( + chalk.dim(" You can install one now (recommended) or continue and install later.\n"), ); + const choice = await promptSelect("Choose runtime to install:", [ + ...AGENT_INSTALL_OPTIONS.map((option) => ({ + value: option.id, + label: option.label, + hint: [option.cmd, ...option.args].join(" "), + })), + { value: "skip", label: "Skip for now" }, + ]); if (choice === "skip") { return available; } @@ -660,13 +657,15 @@ async function autoCreateConfig(workingDir: string): Promise console.log(chalk.dim(" Use 'ao start' to start with the existing config.\n")); return loadConfig(outputPath); } - const yamlContent = yamlStringify(config, { indent: 2 }); + const yamlContent = configToYaml(config); writeFileSync(outputPath, yamlContent); console.log(chalk.green(`✓ Config created: ${outputPath}\n`)); if (!repo) { - console.log(chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable.")); + console.log( + chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."), + ); console.log(chalk.dim(" Add a 'repo' field (owner/repo) to the config to enable them.\n")); } @@ -674,7 +673,9 @@ async function autoCreateConfig(workingDir: string): Promise console.log(chalk.yellow("⚠ tmux not found — will prompt to install at startup")); } if (!env.hasGh) { - console.log(chalk.yellow("⚠ GitHub CLI (gh) not found — optional, but recommended for GitHub workflows.")); + console.log( + chalk.yellow("⚠ GitHub CLI (gh) not found — optional, but recommended for GitHub workflows."), + ); const shouldInstallGh = await askYesNo("Install GitHub CLI now?", false); if (shouldInstallGh) { const installedGh = await tryInstallWithAttempts( @@ -713,13 +714,17 @@ async function addProjectToConfig( const canonicalPath = realpathSync(resolvedPath); const existingByPath = Object.entries(config.projects).find(([, p]) => { try { - return realpathSync(resolve(p.path.replace(/^~/, process.env["HOME"] || ""))) === canonicalPath; + return ( + realpathSync(resolve(p.path.replace(/^~/, process.env["HOME"] || ""))) === canonicalPath + ); } catch { return false; } }); if (existingByPath) { - console.log(chalk.dim(` Path already configured as project "${existingByPath[0]}" — skipping add.`)); + console.log( + chalk.dim(` Path already configured as project "${existingByPath[0]}" — skipping add.`), + ); return existingByPath[0]; } @@ -732,7 +737,9 @@ async function addProjectToConfig( let i = 2; while (config.projects[`${projectId}-${i}`]) i++; const newId = `${projectId}-${i}`; - console.log(chalk.yellow(` ⚠ Project "${projectId}" already exists — using "${newId}" instead.`)); + console.log( + chalk.yellow(` ⚠ Project "${projectId}" already exists — using "${newId}" instead.`), + ); projectId = newId; } @@ -813,10 +820,7 @@ async function addProjectToConfig( config.configPath, ); - writeProjectBehaviorConfig( - resolvedPath, - agentRules ? { agentRules } : {}, - ); + writeProjectBehaviorConfig(resolvedPath, agentRules ? { agentRules } : {}); console.log(chalk.green(`\n✓ Added "${projectId}" to ${config.configPath}\n`)); } else { @@ -834,12 +838,14 @@ async function addProjectToConfig( ...(agentRules ? { agentRules } : {}), }; - writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 })); + writeFileSync(config.configPath, configToYaml(rawConfig as Record)); console.log(chalk.green(`\n✓ Added "${projectId}" to ${config.configPath}\n`)); } if (!ownerRepo) { - console.log(chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable.")); + console.log( + chalk.yellow("⚠ No repo configured — issue tracking and PR features will be unavailable."), + ); console.log(chalk.dim(" Add a 'repo' field (owner/repo) to the config to enable them.\n")); } @@ -931,7 +937,11 @@ function tmuxInstallAttempts(): InstallAttempt[] { } if (process.platform === "linux") { return [ - { cmd: "sudo", args: ["apt-get", "install", "-y", "tmux"], label: "sudo apt-get install -y tmux" }, + { + cmd: "sudo", + args: ["apt-get", "install", "-y", "tmux"], + label: "sudo apt-get install -y tmux", + }, { cmd: "sudo", args: ["dnf", "install", "-y", "tmux"], label: "sudo dnf install -y tmux" }, ]; } @@ -940,21 +950,16 @@ function tmuxInstallAttempts(): InstallAttempt[] { function tmuxInstallHints(): string[] { if (process.platform === "darwin") return ["brew install tmux"]; - if (process.platform === "win32") return [ - "# Install WSL first, then inside WSL:", - "sudo apt install tmux", - ]; - return [ - "sudo apt install tmux # Debian/Ubuntu", - "sudo dnf install tmux # Fedora/RHEL", - ]; + if (process.platform === "win32") + return ["# Install WSL first, then inside WSL:", "sudo apt install tmux"]; + return ["sudo apt install tmux # Debian/Ubuntu", "sudo dnf install tmux # Fedora/RHEL"]; } async function ensureTmux(): Promise { const hasTmux = (await execSilent("tmux", ["-V"])) !== null; if (hasTmux) return; - console.log(chalk.yellow("⚠ tmux is required for runtime \"tmux\".")); + console.log(chalk.yellow('⚠ tmux is required for runtime "tmux".')); const shouldInstall = await askYesNo("Install tmux now?", true, false); if (shouldInstall) { const installed = await tryInstallWithAttempts( @@ -979,7 +984,8 @@ async function ensureTmux(): Promise { async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise { const openclawConfig = config.notifiers?.["openclaw"]; const openclawConfigured = - openclawConfig !== null && openclawConfig !== undefined && + openclawConfig !== null && + openclawConfig !== undefined && typeof openclawConfig === "object" && openclawConfig.plugin === "openclaw"; const configuredUrl = @@ -1045,8 +1051,10 @@ async function runStartup( // This avoids exposing API keys to projects/plugins that don't need them. const openclawNotifier = config.notifiers?.["openclaw"]; const hasOpenClaw = - openclawNotifier !== null && openclawNotifier !== undefined && - typeof openclawNotifier === "object" && openclawNotifier.plugin === "openclaw"; + openclawNotifier !== null && + openclawNotifier !== undefined && + typeof openclawNotifier === "object" && + openclawNotifier.plugin === "openclaw"; if (hasOpenClaw) { const injectedKeys = applyOpenClawCredentials(); if (injectedKeys.length > 0) { @@ -1106,9 +1114,7 @@ async function runStartup( spinner.start("Starting lifecycle worker"); lifecycleStatus = await ensureLifecycleWorker(config, projectId); spinner.succeed( - lifecycleStatus.started - ? "Lifecycle polling started" - : "Lifecycle polling already running", + lifecycleStatus.started ? "Lifecycle polling started" : "Lifecycle polling already running", ); } catch (err) { spinner.fail("Lifecycle worker failed to start"); @@ -1340,8 +1346,16 @@ export function registerStart(program: Command): void { "AO is already running. What do you want to do?", [ { value: "open", label: "Open dashboard", hint: "Keep the current instance" }, - { value: "new", label: "Start new orchestrator", hint: "Add a new session for this project" }, - { value: "restart", label: "Restart everything", hint: "Stop the current instance first" }, + { + value: "new", + label: "Start new orchestrator", + hint: "Add a new session for this project", + }, + { + value: "restart", + label: "Restart everything", + hint: "Stop the current instance first", + }, { value: "quit", label: "Quit" }, ], "open", @@ -1356,10 +1370,18 @@ export function registerStart(program: Command): void { // Defer config mutation until after config is loaded below startNewOrchestrator = true; } else if (choice === "restart") { - try { process.kill(running.pid, "SIGTERM"); } catch { /* already dead */ } + try { + process.kill(running.pid, "SIGTERM"); + } catch { + /* already dead */ + } if (!(await waitForExit(running.pid, 5000))) { console.log(chalk.yellow(" Process didn't exit cleanly, sending SIGKILL...")); - try { process.kill(running.pid, "SIGKILL"); } catch { /* already dead */ } + try { + process.kill(running.pid, "SIGKILL"); + } catch { + /* already dead */ + } if (!(await waitForExit(running.pid, 3000))) { throw new Error( `Failed to stop AO process (PID ${running.pid}). Check permissions or stop it manually.`, @@ -1417,7 +1439,8 @@ export function registerStart(program: Command): void { // Check if project is already in config (match by path) const existingEntry = Object.entries(config.projects).find( - ([, p]) => resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath, + ([, p]) => + resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath, ); if (existingEntry) { @@ -1456,9 +1479,9 @@ export function registerStart(program: Command): void { // Collect existing prefixes to avoid collisions const existingPrefixes = new Set( - Object.values(rawConfig.projects as Record>).map( - (p) => p.sessionPrefix as string, - ).filter(Boolean), + Object.values(rawConfig.projects as Record>) + .map((p) => p.sessionPrefix as string) + .filter(Boolean), ); let newId: string; @@ -1473,7 +1496,10 @@ export function registerStart(program: Command): void { ...rawConfig.projects[projectId], sessionPrefix: newPrefix, }; - writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 })); + const nextYaml = isCanonicalGlobalConfigPath(config.configPath) + ? yamlStringify(rawConfig, { indent: 2 }) + : configToYaml(rawConfig as Record); + writeFileSync(config.configPath, nextYaml); console.log(chalk.green(`\n✓ New orchestrator "${newId}" added to config\n`)); config = loadConfig(config.configPath); projectId = newId; @@ -1503,10 +1529,9 @@ export function registerStart(program: Command): void { const proj = rawConfig.projects[projectId]; proj.orchestrator = { ...(proj.orchestrator ?? {}), agent: orchestratorAgent }; proj.worker = { ...(proj.worker ?? {}), agent: workerAgent }; - writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 })); + writeFileSync(config.configPath, configToYaml(rawConfig as Record)); console.log(chalk.dim(` ✓ Saved to ${config.configPath}\n`)); } - config = loadConfig(config.configPath); project = config.projects[projectId]; } @@ -1573,98 +1598,13 @@ export function registerStop(program: Command): void { .description("Stop orchestrator agent and dashboard") .option("--purge-session", "Delete mapped OpenCode session when stopping") .option("--all", "Stop all running AO instances") - .action( - async ( - projectArg?: string, - opts: { purgeSession?: boolean; all?: boolean } = {}, - ) => { - try { - // Check running.json first - const running = await getRunning(); + .action(async (projectArg?: string, opts: { purgeSession?: boolean; all?: boolean } = {}) => { + 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) { - try { - process.kill(running.pid, "SIGTERM"); - } catch { - // Already dead - } - 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; - } - - const config = loadConfig(); - const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"); - const port = config.port ?? DEFAULT_PORT; - - console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); - - // Resolve the actual orchestrator session id by listing the project's sessions - // and finding the most-recently-active orchestrator. This avoids relying on the - // legacy `${prefix}-orchestrator` (no-N) phantom id, which never matches a real - // numbered session and causes ao stop to silently no-op. - const sm = await getSessionManager(config); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - let orchestratorToKill: { id: string } | null = null; - let lookupFailed = false; - try { - const projectSessions = await sm.list(_projectId); - const orchestrators = projectSessions - .filter((s) => - isOrchestratorSession(s, project.sessionPrefix ?? _projectId, allSessionPrefixes), - ) - .filter((s) => !isTerminalSession(s)); - const sorted = [...orchestrators].sort( - (a, b) => - (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0), - ); - orchestratorToKill = sorted[0] ?? null; - } catch (err) { - lookupFailed = true; - console.log( - chalk.yellow( - ` Could not list sessions to locate orchestrator: ${ - err instanceof Error ? err.message : String(err) - }`, - ), - ); - } - - if (orchestratorToKill) { - const spinner = ora("Stopping orchestrator session").start(); - const purgeOpenCode = opts?.purgeSession === true; - await sm.kill(orchestratorToKill.id, { purgeOpenCode }); - spinner.succeed(`Orchestrator session stopped (${orchestratorToKill.id})`); - // Also log to console.log so the killed id is visible in non-TTY callers - // (CI, scripts) and in test capture, since spinner output is suppressed. - console.log(chalk.green(` Stopped orchestrator session: ${orchestratorToKill.id}`)); - } else if (!lookupFailed) { - // Suppress the "no orchestrator found" message when sm.list threw — - // the catch above already explained the real reason and adding a - // second message would falsely imply the lookup succeeded. - console.log( - chalk.yellow(`No running orchestrator session found for "${project.name}"`), - ); - } - - // 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. - - // Stop dashboard — kill parent PID from running.json, then also stop - // any dashboard child process via lsof (parent SIGTERM may not propagate) + if (opts.all) { + // --all: kill via running.json if available, then fallback to config if (running) { try { process.kill(running.pid, "SIGTERM"); @@ -1672,23 +1612,95 @@ export function registerStop(program: Command): void { // Already dead } await unregister(); - } - await stopDashboard(running?.port ?? port); - - 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) { - if (err instanceof Error) { - console.error(chalk.red("\nError:"), err.message); + console.log(chalk.green(`\n✓ Stopped AO on port ${running.port}`)); + console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`)); } else { - console.error(chalk.red("\nError:"), String(err)); + console.log(chalk.yellow("No running AO instance found in running.json.")); } - process.exit(1); + return; } - }); + + const config = loadConfig(); + const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"); + const port = config.port ?? DEFAULT_PORT; + + console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); + + // Resolve the actual orchestrator session id by listing the project's sessions + // and finding the most-recently-active orchestrator. This avoids relying on the + // legacy `${prefix}-orchestrator` (no-N) phantom id, which never matches a real + // numbered session and causes ao stop to silently no-op. + const sm = await getSessionManager(config); + const allSessionPrefixes = Object.entries(config.projects).map( + ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), + ); + let orchestratorToKill: { id: string } | null = null; + let lookupFailed = false; + try { + const projectSessions = await sm.list(_projectId); + const orchestrators = projectSessions + .filter((s) => + isOrchestratorSession(s, project.sessionPrefix ?? _projectId, allSessionPrefixes), + ) + .filter((s) => !isTerminalSession(s)); + const sorted = [...orchestrators].sort( + (a, b) => (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0), + ); + orchestratorToKill = sorted[0] ?? null; + } catch (err) { + lookupFailed = true; + console.log( + chalk.yellow( + ` Could not list sessions to locate orchestrator: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + } + + if (orchestratorToKill) { + const spinner = ora("Stopping orchestrator session").start(); + const purgeOpenCode = opts?.purgeSession === true; + await sm.kill(orchestratorToKill.id, { purgeOpenCode }); + spinner.succeed(`Orchestrator session stopped (${orchestratorToKill.id})`); + // Also log to console.log so the killed id is visible in non-TTY callers + // (CI, scripts) and in test capture, since spinner output is suppressed. + console.log(chalk.green(` Stopped orchestrator session: ${orchestratorToKill.id}`)); + } else if (!lookupFailed) { + // Suppress the "no orchestrator found" message when sm.list threw — + // the catch above already explained the real reason and adding a + // second message would falsely imply the lookup succeeded. + console.log(chalk.yellow(`No running orchestrator session found for "${project.name}"`)); + } + + // 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. + + // Stop dashboard — kill parent PID from running.json, then also stop + // any dashboard child process via lsof (parent SIGTERM may not propagate) + if (running) { + try { + process.kill(running.pid, "SIGTERM"); + } catch { + // Already dead + } + await unregister(); + } + await stopDashboard(running?.port ?? port); + + 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) { + if (err instanceof Error) { + console.error(chalk.red("\nError:"), err.message); + } else { + console.error(chalk.red("\nError:"), String(err)); + } + process.exit(1); + } + }); } diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts index a67783f17..edb119a7e 100644 --- a/packages/cli/src/lib/config-instruction.ts +++ b/packages/cli/src/lib/config-instruction.ts @@ -2,11 +2,15 @@ * Returns the complete AO config schema as formatted text. * Used by `ao config-help` and injected into orchestrator system prompts. */ +import { CONFIG_SCHEMA_URL } from "@aoagents/ao-core"; + export function getConfigInstruction(): string { return ` # Agent Orchestrator Config Reference # File: agent-orchestrator.yaml +$schema: ${CONFIG_SCHEMA_URL} + # ── Top-level settings ────────────────────────────────────────────── # Runtime data paths are auto-derived from the config location under: # ~/.agent-orchestrator/{hash}-{projectId}/ diff --git a/packages/core/__tests__/config.test.ts b/packages/core/__tests__/config.test.ts index 66b695440..7d781c318 100644 --- a/packages/core/__tests__/config.test.ts +++ b/packages/core/__tests__/config.test.ts @@ -77,6 +77,26 @@ describe("Config Loading", () => { }); describe("loadConfig", () => { + it("should accept and preserve a top-level $schema property", () => { + const configPath = join(testDir, "schema-config.yaml"); + writeFileSync( + configPath, + ` +$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json +projects: + test-project: + repo: test/repo + path: ${testDir} + defaultBranch: main +`, + ); + + const config = loadConfig(configPath); + expect(config["$schema"]).toBe( + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + ); + }); + it("should load config from AO_CONFIG_PATH env var", () => { const configPath = join(testDir, "test-config.yaml"); writeFileSync( diff --git a/packages/core/src/__tests__/config-generator.test.ts b/packages/core/src/__tests__/config-generator.test.ts index cd818af83..2f047ff9e 100644 --- a/packages/core/src/__tests__/config-generator.test.ts +++ b/packages/core/src/__tests__/config-generator.test.ts @@ -3,10 +3,12 @@ */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + withConfigSchema, + CONFIG_SCHEMA_URL, isRepoUrl, parseRepoUrl, detectScmPlatform, @@ -19,6 +21,25 @@ import { sanitizeProjectId, } from "../config-generator.js"; +describe("withConfigSchema", () => { + it("uses canonical schema URL for missing schema", () => { + const config = withConfigSchema({ port: 3000 }); + expect(config.$schema).toBe(CONFIG_SCHEMA_URL); + expect(config.port).toBe(3000); + }); + + it("treats blank and whitespace schema values as missing", () => { + expect(withConfigSchema({ $schema: "" }).$schema).toBe(CONFIG_SCHEMA_URL); + expect(withConfigSchema({ $schema: " " }).$schema).toBe(CONFIG_SCHEMA_URL); + }); + + it("preserves non-empty schema values", () => { + const custom = "https://example.com/schema.json"; + const config = withConfigSchema({ $schema: custom }); + expect(config.$schema).toBe(custom); + }); +}); + // ============================================================================= // isRepoUrl // ============================================================================= @@ -94,9 +115,7 @@ describe("parseRepoUrl", () => { it("throws on invalid URL", () => { expect(() => parseRepoUrl("not-a-url")).toThrow("Could not parse repo URL"); - expect(() => parseRepoUrl("https://github.com/just-owner")).toThrow( - "Could not parse repo URL", - ); + expect(() => parseRepoUrl("https://github.com/just-owner")).toThrow("Could not parse repo URL"); }); }); @@ -250,6 +269,9 @@ describe("generateConfigFromUrl", () => { const config = generateConfigFromUrl({ parsed, repoPath: tmpDir }); // Check top-level structure + expect(config["$schema"]).toBe( + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + ); expect(config.port).toBe(3000); expect(config.defaults).toEqual({ runtime: "tmux", @@ -394,11 +416,37 @@ describe("configToYaml", () => { it("serializes config to valid YAML", () => { const config = { port: 3000, projects: { app: { name: "App" } } }; const yaml = configToYaml(config); + expect(yaml).toContain( + "$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + ); expect(yaml).toContain("port: 3000"); expect(yaml).toContain("name: App"); }); }); +describe("config schema", () => { + it("documents runtime-populated project identity fields", () => { + const schema = JSON.parse( + readFileSync(new URL("../../../../schema/config.schema.json", import.meta.url), "utf-8"), + ) as { + $defs: { + projectConfig: { + properties: Record; + }; + }; + }; + + expect(schema.$defs.projectConfig.properties["storageKey"]).toMatchObject({ type: "string" }); + expect(schema.$defs.projectConfig.properties["originUrl"]).toMatchObject({ + type: "string", + format: "uri", + }); + expect(schema.$defs.projectConfig.properties["resolveError"]).toMatchObject({ + type: "string", + }); + }); +}); + // ============================================================================= // isRepoAlreadyCloned // ============================================================================= diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index d61e4ea93..a9265e2ea 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -73,6 +73,31 @@ describe("Config Validation - Project Uniqueness", () => { }); }); +describe("Config Validation - Numeric Fields", () => { + const baseConfig = { + projects: { + app: { + path: "/repos/app", + }, + }, + }; + + it("rejects fractional ports", () => { + expect(() => validateConfig({ ...baseConfig, port: 3000.5 })).toThrow(); + expect(() => validateConfig({ ...baseConfig, terminalPort: 14800.5 })).toThrow(); + expect(() => validateConfig({ ...baseConfig, directTerminalPort: 14801.5 })).toThrow(); + }); + + it("rejects fractional lifecycle grace periods", () => { + expect(() => + validateConfig({ + ...baseConfig, + lifecycle: { mergeCleanupIdleGraceMs: 300_000.5 }, + }), + ).toThrow(); + }); +}); + describe("Config Validation - Session Prefix Uniqueness", () => { it("rejects duplicate explicit prefixes", () => { const config = { diff --git a/packages/core/src/config-generator.ts b/packages/core/src/config-generator.ts index e3770d4b5..e1fde368a 100644 --- a/packages/core/src/config-generator.ts +++ b/packages/core/src/config-generator.ts @@ -10,6 +10,28 @@ import { join, resolve } from "node:path"; import { stringify as yamlStringify } from "yaml"; import { generateSessionPrefix } from "./paths.js"; +// Main is the canonical config-schema contract; schema evolution must remain +// forward-compatible and additive for older CLIs that stamp this URL. +export const CONFIG_SCHEMA_URL = + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json"; + +export function withConfigSchema>( + config: T, +): T & { $schema: string } { + const { $schema: providedSchema, ...rest } = config as { + $schema?: unknown; + }; + const schema = + typeof providedSchema === "string" && providedSchema.trim().length > 0 + ? providedSchema + : CONFIG_SCHEMA_URL; + + return { + $schema: schema, + ...rest, + } as T & { $schema: string }; +} + // ============================================================================= // URL PARSING // ============================================================================= @@ -258,7 +280,7 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record): string { - return yamlStringify(config, { indent: 2 }); + return yamlStringify(withConfigSchema(config), { indent: 2 }); } /** @@ -302,7 +324,10 @@ export function isRepoAlreadyCloned(dir: string, expectedCloneUrl: string): bool if (sshMatch) { normalized = `https://${sshMatch[1]}/${sshMatch[2]}`; } - return normalized.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase(); + return normalized + .replace(/\.git$/, "") + .replace(/\/$/, "") + .toLowerCase(); }; const expectedNorm = normalize(expectedCloneUrl); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 09955c85e..2d6aa74d2 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -27,6 +27,7 @@ import { import { generateSessionPrefix } from "./paths.js"; import { getGlobalConfigPath, + isCanonicalGlobalConfigPath, loadGlobalConfig, } from "./global-config.js"; import { loadEffectiveProjectConfig } from "./project-resolver.js"; @@ -59,18 +60,14 @@ function inferScmPlugin(project: { return "github"; } -function classifyConfigShape( - configPath: string, -): "wrapped" | "flat-or-nonobject" | "missing" { +function classifyConfigShape(configPath: string): "wrapped" | "flat-or-nonobject" | "missing" { if (!existsSync(configPath)) { return "missing"; } const raw = readFileSync(configPath, "utf-8"); const parsed = parseYaml(raw); - return parsed && - typeof parsed === "object" && - "projects" in (parsed as Record) + return parsed && typeof parsed === "object" && "projects" in (parsed as Record) ? "wrapped" : "flat-or-nonobject"; } @@ -82,38 +79,41 @@ function generateLegacyWrappedStorageKey(configPath: string, projectPath: string return `${hash}-${basename(projectPath)}`; } -function applyWrappedLocalStorageKeys( - configPath: string, - parsed: unknown, -): unknown { +function applyWrappedLocalStorageKeys(configPath: string, parsed: unknown): unknown { if (!parsed || typeof parsed !== "object") return parsed; const parsedObject = parsed as Record; - if (!("projects" in parsedObject) || !parsedObject["projects"] || typeof parsedObject["projects"] !== "object") { + if ( + !("projects" in parsedObject) || + !parsedObject["projects"] || + typeof parsedObject["projects"] !== "object" + ) { return parsed; } return { ...parsedObject, projects: Object.fromEntries( - Object.entries(parsedObject["projects"] as Record).map(([projectId, value]) => { - if (!value || typeof value !== "object") { - return [projectId, value]; - } + Object.entries(parsedObject["projects"] as Record).map( + ([projectId, value]) => { + if (!value || typeof value !== "object") { + return [projectId, value]; + } - const project = value as Record; - if (typeof project["storageKey"] === "string" || typeof project["path"] !== "string") { - return [projectId, value]; - } + const project = value as Record; + if (typeof project["storageKey"] === "string" || typeof project["path"] !== "string") { + return [projectId, value]; + } - return [ - projectId, - { - ...project, - storageKey: generateLegacyWrappedStorageKey(configPath, project["path"]), - }, - ]; - }), + return [ + projectId, + { + ...project, + storageKey: generateLegacyWrappedStorageKey(configPath, project["path"]), + }, + ]; + }, + ), ), }; } @@ -338,6 +338,7 @@ const LifecycleConfigSchema = z */ mergeCleanupIdleGraceMs: z .number() + .int() .nonnegative() .refine((v) => v === 0 || v >= 10_000, { message: @@ -348,17 +349,23 @@ const LifecycleConfigSchema = z .default({}); const OrchestratorConfigSchema = z.object({ - port: z.number().default(3000), - terminalPort: z.number().optional(), - directTerminalPort: z.number().optional(), - readyThresholdMs: z.number().nonnegative().default(300_000), + $schema: z.string().optional(), + port: z.number().int().default(3000), + terminalPort: z.number().int().optional(), + directTerminalPort: z.number().int().optional(), + readyThresholdMs: z.number().int().nonnegative().default(300_000), power: PowerConfigSchema, lifecycle: LifecycleConfigSchema, defaults: DefaultPluginsSchema.default({}), plugins: z.array(InstalledPluginConfigSchema).default([]), dashboard: DashboardConfigSchema.optional(), projects: z.record( - z.string().regex(/^[a-zA-Z0-9_-]+$/, "Project ID must match [a-zA-Z0-9_-]+ (no dots, slashes, or special characters)"), + z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + "Project ID must match [a-zA-Z0-9_-]+ (no dots, slashes, or special characters)", + ), ProjectConfigSchema, ), notifiers: z.record(NotifierConfigSchema).default({}), @@ -409,7 +416,9 @@ function generateTempPluginName(pkg?: string, path?: string): string { const packageName = slashParts[slashParts.length - 1] ?? pkg; // Extract plugin name after ao-plugin-{slot}- prefix, preserving multi-word names like "jira-cloud" - const prefixMatch = packageName.match(/^ao-plugin-(?:runtime|agent|workspace|tracker|scm|notifier|terminal)-(.+)$/); + const prefixMatch = packageName.match( + /^ao-plugin-(?:runtime|agent|workspace|tracker|scm|notifier|terminal)-(.+)$/, + ); if (prefixMatch?.[1]) { return prefixMatch[1]; } @@ -543,8 +552,7 @@ function mergeExternalPlugins( // If the existing plugin is disabled but there's an inline reference, enable it const existingPlugin = plugins.find( (p) => - (entry.package && p.package === entry.package) || - (entry.path && p.path === entry.path), + (entry.package && p.package === entry.package) || (entry.path && p.path === entry.path), ); if (existingPlugin && existingPlugin.enabled === false) { existingPlugin.enabled = true; @@ -926,17 +934,17 @@ export function loadConfig(configPath?: string): LoadedConfig { const raw = readFileSync(path, "utf-8"); const parsed = parseYaml(raw); const shape = classifyConfigShape(path); - const isCanonicalGlobalConfig = resolve(path) === resolve(getGlobalConfigPath()); + const isCanonicalGlobalConfig = isCanonicalGlobalConfigPath(path); const normalizedParsed = !isCanonicalGlobalConfig && shape === "wrapped" ? applyWrappedLocalStorageKeys(path, parsed) : parsed; - const config = - isCanonicalGlobalConfig - ? buildEffectiveConfigFromGlobalConfigPath(path) ?? validateConfig(normalizedParsed) - : shape === "wrapped" + const config = isCanonicalGlobalConfig + ? (buildEffectiveConfigFromGlobalConfigPath(path) ?? validateConfig(normalizedParsed)) + : shape === "wrapped" ? validateConfig(normalizedParsed) - : buildEffectiveConfigFromFlatLocalPath(path, normalizedParsed) ?? validateConfig(normalizedParsed); + : (buildEffectiveConfigFromFlatLocalPath(path, normalizedParsed) ?? + validateConfig(normalizedParsed)); // Set the config path in the config object for hash generation config.configPath = path; @@ -961,17 +969,17 @@ export function loadConfigWithPath(configPath?: string): { const raw = readFileSync(path, "utf-8"); const parsed = parseYaml(raw); const shape = classifyConfigShape(path); - const isCanonicalGlobalConfig = resolve(path) === resolve(getGlobalConfigPath()); + const isCanonicalGlobalConfig = isCanonicalGlobalConfigPath(path); const normalizedParsed = !isCanonicalGlobalConfig && shape === "wrapped" ? applyWrappedLocalStorageKeys(path, parsed) : parsed; - const config = - isCanonicalGlobalConfig - ? buildEffectiveConfigFromGlobalConfigPath(path) ?? validateConfig(normalizedParsed) - : shape === "wrapped" + const config = isCanonicalGlobalConfig + ? (buildEffectiveConfigFromGlobalConfigPath(path) ?? validateConfig(normalizedParsed)) + : shape === "wrapped" ? validateConfig(normalizedParsed) - : buildEffectiveConfigFromFlatLocalPath(path, normalizedParsed) ?? validateConfig(normalizedParsed); + : (buildEffectiveConfigFromFlatLocalPath(path, normalizedParsed) ?? + validateConfig(normalizedParsed)); // Set the config path in the config object for hash generation config.configPath = path; diff --git a/packages/core/src/global-config.ts b/packages/core/src/global-config.ts index b8e583f33..6e35b9e27 100644 --- a/packages/core/src/global-config.ts +++ b/packages/core/src/global-config.ts @@ -16,12 +16,7 @@ import { atomicWriteFileSync } from "./atomic-write.js"; import { detectScmPlatform } from "./config-generator.js"; import { withFileLockSync } from "./file-lock.js"; import { ProjectResolveError } from "./types.js"; -import { - generateSessionPrefix, - getAoBaseDir, - getProjectBaseDir, - getSessionsDir, -} from "./paths.js"; +import { generateSessionPrefix, getAoBaseDir, getProjectBaseDir, getSessionsDir } from "./paths.js"; import { deriveStorageKey, normalizeOriginUrl } from "./storage-key.js"; function globalConfigLockPath(configPath: string): string { @@ -110,6 +105,11 @@ export function getGlobalConfigPath(): string { return join(homedir(), ".agent-orchestrator", "config.yaml"); } +export function isCanonicalGlobalConfigPath(configPath: string | undefined): boolean { + if (!configPath) return false; + return resolve(configPath) === resolve(getGlobalConfigPath()); +} + // ============================================================================= // GLOBAL CONFIG SCHEMA // ============================================================================= @@ -134,7 +134,14 @@ const GLOBAL_PROJECT_ENTRY_FIELDS = new Set([ ]); const LOCAL_CONFIG_FILENAMES = ["agent-orchestrator.yaml", "agent-orchestrator.yml"] as const; -const LOCAL_IDENTITY_FIELDS = new Set(["repo", "defaultBranch", "originUrl", "projectId", "path", "storageKey"]); +const LOCAL_IDENTITY_FIELDS = new Set([ + "repo", + "defaultBranch", + "originUrl", + "projectId", + "path", + "storageKey", +]); export const GlobalProjectEntrySchema = z.object({ projectId: z.string().optional(), @@ -336,7 +343,7 @@ export function saveGlobalConfig(config: GlobalConfig, configPath?: string): voi */ export function loadLocalProjectConfig(projectPath: string): LocalProjectConfig | null { const result = loadLocalProjectConfigDetailed(projectPath); - return result.kind === "loaded" ? result.config ?? null : null; + return result.kind === "loaded" ? (result.config ?? null) : null; } export function loadLocalProjectConfigDetailed(projectPath: string): LocalProjectConfigLoadResult { @@ -438,7 +445,9 @@ export function repairWrappedLocalProjectConfig(projectId: string, projectPath: const projects = (parsed["projects"] ?? {}) as Record>; const project = projects[projectId]; if (!project || typeof project !== "object") { - throw new Error(`Wrapped local config at ${configPath} does not contain project "${projectId}".`); + throw new Error( + `Wrapped local config at ${configPath} does not contain project "${projectId}".`, + ); } const { @@ -533,9 +542,13 @@ function readOriginUrlFromGitConfig(projectPath: string): string | null { return null; } -function deriveProjectStorageIdentity(projectPath: string, originUrlOverride?: string | null): StorageIdentity { +function deriveProjectStorageIdentity( + projectPath: string, + originUrlOverride?: string | null, +): StorageIdentity { const gitRoot = resolveGitRoot(projectPath); - const rawOriginUrl = originUrlOverride !== undefined ? originUrlOverride : readOriginUrlFromGitConfig(projectPath); + const rawOriginUrl = + originUrlOverride !== undefined ? originUrlOverride : readOriginUrlFromGitConfig(projectPath); const originUrl = rawOriginUrl === null ? null : normalizeOriginUrl(rawOriginUrl); return { gitRoot, @@ -544,7 +557,9 @@ function deriveProjectStorageIdentity(projectPath: string, originUrlOverride?: s }; } -function normalizeRepoIdentity(originUrl: string | null): z.infer | undefined { +function normalizeRepoIdentity( + originUrl: string | null, +): z.infer | undefined { if (!originUrl) return undefined; const normalizedOriginUrl = normalizeOriginUrl(originUrl); @@ -665,7 +680,9 @@ function ensureProjectStorageIdentity( globalConfigPath?: string, alreadyLocked = false, ): (GlobalProjectEntry & Record) | null { - const entry = globalConfig.projects[projectId] as (GlobalProjectEntry & Record) | undefined; + const entry = globalConfig.projects[projectId] as + | (GlobalProjectEntry & Record) + | undefined; if (!entry?.path) return null; if (typeof entry.storageKey === "string") { @@ -683,7 +700,9 @@ function ensureProjectStorageIdentity( const configPath = globalConfigPath ?? getGlobalConfigPath(); const migrate = () => { const fresh = loadGlobalConfig(configPath, { alreadyLocked: true }) ?? globalConfig; - const freshEntry = fresh.projects[projectId] as (GlobalProjectEntry & Record) | undefined; + const freshEntry = fresh.projects[projectId] as + | (GlobalProjectEntry & Record) + | undefined; if (!freshEntry?.path) return; const freshOldStorageKey = @@ -758,7 +777,8 @@ export function registerProjectInGlobalConfig( optionsOrGlobalConfigPath?: RegisterProjectOptions | string, globalConfigPath?: string, ): void { - const options = typeof optionsOrGlobalConfigPath === "string" ? {} : (optionsOrGlobalConfigPath ?? {}); + const options = + typeof optionsOrGlobalConfigPath === "string" ? {} : (optionsOrGlobalConfigPath ?? {}); const configPath = typeof optionsOrGlobalConfigPath === "string" ? optionsOrGlobalConfigPath @@ -768,7 +788,8 @@ export function registerProjectInGlobalConfig( const identity = deriveProjectStorageIdentity(normalizedProjectPath); withFileLockSync(globalConfigLockPath(configPath), () => { - const globalConfig = loadGlobalConfig(configPath, { alreadyLocked: true }) ?? makeEmptyGlobalConfig(); + const globalConfig = + loadGlobalConfig(configPath, { alreadyLocked: true }) ?? makeEmptyGlobalConfig(); const existing = globalConfig.projects[projectId] as | (GlobalProjectEntry & Record) @@ -872,16 +893,18 @@ export function resolveProjectIdentity( projectId: string, globalConfig: GlobalConfig, globalConfigPath?: string, -): (Record & { - name: string; - path: string; - storageKey: string; - originUrl?: string; - repo?: string; - defaultBranch: string; - sessionPrefix: string; - resolveError?: string; -}) | null { +): + | (Record & { + name: string; + path: string; + storageKey: string; + originUrl?: string; + repo?: string; + defaultBranch: string; + sessionPrefix: string; + resolveError?: string; + }) + | null { const entry = globalConfig.projects[projectId] as | (GlobalProjectEntry & Record) | undefined; @@ -967,7 +990,9 @@ export function resolveProjectIdentity( } const resolveError = - localConfigResult.kind !== "missing" ? localConfigResult.error ?? "Failed to load local config" : undefined; + localConfigResult.kind !== "missing" + ? (localConfigResult.error ?? "Failed to load local config") + : undefined; return { ...(resolveError ? {} : applyBehaviorDefaults({})), @@ -985,8 +1010,11 @@ export function relinkProjectInGlobalConfig( let result: { oldStorageKey: string; storageKey: string; originUrl: string } | null = null; withFileLockSync(globalConfigLockPath(configPath), () => { - const globalConfig = loadGlobalConfig(configPath, { alreadyLocked: true }) ?? makeEmptyGlobalConfig(); - const entry = globalConfig.projects[projectId] as (GlobalProjectEntry & Record) | undefined; + const globalConfig = + loadGlobalConfig(configPath, { alreadyLocked: true }) ?? makeEmptyGlobalConfig(); + const entry = globalConfig.projects[projectId] as + | (GlobalProjectEntry & Record) + | undefined; if (!entry?.path) { throw new Error(`Project "${projectId}" is not registered in the global config.`); } @@ -1057,7 +1085,10 @@ export function isOldConfigFormat(raw: unknown): boolean { const projects = obj["projects"] as Record; return Object.values(projects).some( (entry) => - entry !== null && entry !== undefined && typeof entry === "object" && "path" in (entry as Record), + entry !== null && + entry !== undefined && + typeof entry === "object" && + "path" in (entry as Record), ); } @@ -1096,7 +1127,8 @@ export function migrateToGlobalConfig(oldConfigPath: string, globalConfigPath?: // Preserve global operational settings if (typeof parsed["port"] === "number") newGlobal.port = parsed["port"]; - if (parsed["terminalPort"] !== null && parsed["terminalPort"] !== undefined) newGlobal.terminalPort = parsed["terminalPort"] as number; + if (parsed["terminalPort"] !== null && parsed["terminalPort"] !== undefined) + newGlobal.terminalPort = parsed["terminalPort"] as number; if (parsed["directTerminalPort"] !== null && parsed["directTerminalPort"] !== undefined) newGlobal.directTerminalPort = parsed["directTerminalPort"] as number; if (parsed["readyThresholdMs"] !== null && parsed["readyThresholdMs"] !== undefined) @@ -1210,9 +1242,7 @@ function makeEmptyGlobalConfig(): GlobalConfig { }; } -function sanitizeRawGlobalConfig( - raw: Record, -): RawGlobalConfigSanitization { +function sanitizeRawGlobalConfig(raw: Record): RawGlobalConfigSanitization { const projects = raw["projects"]; if (!projects || typeof projects !== "object") { return { changed: false, strippedProjects: [] }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1a107d30a..52813d85c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -155,9 +155,7 @@ export { getWorkspaceAgentsMdPath, writeWorkspaceOpenCodeAgentsMd, } from "./opencode-agents-md.js"; -export { - writeOpenCodeConfig, -} from "./opencode-config.js"; +export { writeOpenCodeConfig } from "./opencode-config.js"; export { getOrchestratorSessionId, normalizeOrchestratorSessionStrategy, @@ -249,15 +247,12 @@ export { validateAndStoreOrigin, } from "./paths.js"; -export { - normalizeOriginUrl, - relativeSubdir, - deriveStorageKey, -} from "./storage-key.js"; +export { normalizeOriginUrl, relativeSubdir, deriveStorageKey } from "./storage-key.js"; // Global config — Option C hybrid architecture (global registry + local behavior) export { getGlobalConfigPath, + isCanonicalGlobalConfigPath, loadGlobalConfig, saveGlobalConfig, loadLocalProjectConfig, @@ -283,13 +278,11 @@ export type { RelinkProjectOptions, } from "./global-config.js"; -export { - loadEffectiveProjectConfig, - iterateAllProjects, -} from "./project-resolver.js"; +export { loadEffectiveProjectConfig, iterateAllProjects } from "./project-resolver.js"; // Config generator — auto-generate config from repo URL export { + CONFIG_SCHEMA_URL, isRepoUrl, parseRepoUrl, detectScmPlatform, @@ -297,6 +290,7 @@ export { detectProjectInfo, generateConfigFromUrl, configToYaml, + withConfigSchema, isRepoAlreadyCloned, resolveCloneTarget, sanitizeProjectId, @@ -317,12 +311,7 @@ export type { PortfolioSession, } from "./types.js"; -export { - getAoBaseDir, - getPortfolioDir, - getPreferencesPath, - getRegisteredPath, -} from "./paths.js"; +export { getAoBaseDir, getPortfolioDir, getPreferencesPath, getRegisteredPath } from "./paths.js"; export { discoverProjects, @@ -338,15 +327,9 @@ export { refreshProject, } from "./portfolio-registry.js"; -export { - resolveProjectConfig, - clearConfigCache, -} from "./portfolio-projects.js"; +export { resolveProjectConfig, clearConfigCache } from "./portfolio-projects.js"; -export { - listPortfolioSessions, - getPortfolioSessionCounts, -} from "./portfolio-session-service.js"; +export { listPortfolioSessions, getPortfolioSessionCounts } from "./portfolio-session-service.js"; export { resolvePortfolioProject, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bb361a30f..b540830ee 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1260,6 +1260,9 @@ export interface LifecycleConfig { /** Top-level orchestrator configuration (from agent-orchestrator.yaml) */ export interface OrchestratorConfig { + /** Optional JSON Schema hint for editor autocomplete/validation. */ + "$schema"?: string; + /** * Path to the config file (set automatically during load). * Used for hash-based directory structure. diff --git a/schema/config.schema.json b/schema/config.schema.json new file mode 100644 index 000000000..a5b78af6e --- /dev/null +++ b/schema/config.schema.json @@ -0,0 +1,553 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + "title": "Agent Orchestrator Config", + "description": "Schema for agent-orchestrator.yaml.", + "type": "object", + "additionalProperties": false, + "required": ["projects"], + "properties": { + "$schema": { + "type": "string", + "format": "uri-reference", + "default": "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json", + "description": "Optional schema reference for editor autocomplete and validation." + }, + "port": { + "type": "integer", + "minimum": 0, + "default": 3000, + "description": "Web dashboard port." + }, + "terminalPort": { + "type": "integer", + "minimum": 0, + "description": "Terminal WebSocket server port override." + }, + "directTerminalPort": { + "type": "integer", + "minimum": 0, + "description": "Direct terminal WebSocket server port override." + }, + "readyThresholdMs": { + "type": "integer", + "minimum": 0, + "default": 300000, + "description": "Milliseconds before a ready session becomes idle." + }, + "power": { + "$ref": "#/$defs/powerConfig" + }, + "lifecycle": { + "$ref": "#/$defs/lifecycleConfig" + }, + "defaults": { + "$ref": "#/$defs/defaultPlugins" + }, + "plugins": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/installedPluginConfig" + } + }, + "dashboard": { + "$ref": "#/$defs/dashboardConfig" + }, + "projects": { + "type": "object", + "description": "Project configs keyed by project ID.", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "$ref": "#/$defs/projectConfig" + } + }, + "additionalProperties": false + }, + "notifiers": { + "type": "object", + "default": {}, + "additionalProperties": { + "$ref": "#/$defs/notifierConfig" + } + }, + "notificationRouting": { + "$ref": "#/$defs/notificationRouting" + }, + "reactions": { + "type": "object", + "default": {}, + "additionalProperties": { + "$ref": "#/$defs/reactionConfig" + } + } + }, + "$defs": { + "reactionConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "auto": { + "type": "boolean", + "default": true + }, + "action": { + "type": "string", + "enum": ["send-to-agent", "notify", "auto-merge"], + "default": "notify" + }, + "message": { + "type": "string" + }, + "priority": { + "type": "string", + "enum": ["urgent", "action", "warning", "info"] + }, + "retries": { + "type": "number" + }, + "escalateAfter": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "threshold": { + "type": "string" + }, + "includeSummary": { + "type": "boolean" + } + } + }, + "trackerConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "plugin": { + "type": "string", + "description": "Built-in or installed tracker plugin name." + }, + "package": { + "type": "string", + "description": "npm package for an external tracker plugin." + }, + "path": { + "type": "string", + "description": "Local path for an external tracker plugin." + } + }, + "anyOf": [{ "required": ["plugin"] }, { "required": ["package"] }, { "required": ["path"] }], + "not": { + "required": ["package", "path"] + } + }, + "scmWebhookConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "path": { + "type": "string" + }, + "secretEnvVar": { + "type": "string" + }, + "signatureHeader": { + "type": "string" + }, + "eventHeader": { + "type": "string" + }, + "deliveryHeader": { + "type": "string" + }, + "maxBodyBytes": { + "type": "integer", + "exclusiveMinimum": 0 + } + } + }, + "scmConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "plugin": { + "type": "string", + "description": "Built-in or installed SCM plugin name." + }, + "package": { + "type": "string", + "description": "npm package for an external SCM plugin." + }, + "path": { + "type": "string", + "description": "Local path for an external SCM plugin." + }, + "webhook": { + "$ref": "#/$defs/scmWebhookConfig" + } + }, + "anyOf": [{ "required": ["plugin"] }, { "required": ["package"] }, { "required": ["path"] }], + "not": { + "required": ["package", "path"] + } + }, + "notifierConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "plugin": { + "type": "string", + "description": "Built-in or installed notifier plugin name." + }, + "package": { + "type": "string", + "description": "npm package for an external notifier plugin." + }, + "path": { + "type": "string", + "description": "Local path for an external notifier plugin." + } + }, + "anyOf": [{ "required": ["plugin"] }, { "required": ["package"] }, { "required": ["path"] }], + "not": { + "required": ["package", "path"] + } + }, + "agentPermission": { + "type": "string", + "enum": ["permissionless", "default", "auto-edit", "suggest", "skip"], + "default": "permissionless" + }, + "agentSpecificConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "permissions": { + "$ref": "#/$defs/agentPermission" + }, + "model": { + "type": "string" + }, + "orchestratorModel": { + "type": "string" + }, + "opencodeSessionId": { + "type": "string" + } + } + }, + "roleAgentSpecificConfig": { + "type": "object", + "additionalProperties": true, + "properties": { + "permissions": { + "$ref": "#/$defs/agentPermission" + }, + "model": { + "type": "string" + }, + "orchestratorModel": { + "type": "string" + }, + "opencodeSessionId": { + "type": "string" + } + } + }, + "roleAgentDefaults": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent": { + "type": "string" + } + } + }, + "roleAgentConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent": { + "type": "string" + }, + "agentConfig": { + "$ref": "#/$defs/roleAgentSpecificConfig" + } + } + }, + "projectConfig": { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "name": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "path": { + "type": "string" + }, + "defaultBranch": { + "type": "string", + "default": "main" + }, + "sessionPrefix": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "storageKey": { + "type": "string" + }, + "originUrl": { + "type": "string", + "format": "uri" + }, + "resolveError": { + "type": "string" + }, + "runtime": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "workspace": { + "type": "string" + }, + "tracker": { + "$ref": "#/$defs/trackerConfig" + }, + "scm": { + "$ref": "#/$defs/scmConfig" + }, + "symlinks": { + "type": "array", + "items": { + "type": "string" + } + }, + "postCreate": { + "type": "array", + "items": { + "type": "string" + } + }, + "agentConfig": { + "$ref": "#/$defs/agentSpecificConfig" + }, + "orchestrator": { + "$ref": "#/$defs/roleAgentConfig" + }, + "worker": { + "$ref": "#/$defs/roleAgentConfig" + }, + "reactions": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/reactionConfig" + } + }, + "agentRules": { + "type": "string" + }, + "agentRulesFile": { + "type": "string" + }, + "orchestratorRules": { + "type": "string" + }, + "orchestratorSessionStrategy": { + "type": "string", + "enum": ["reuse", "delete", "ignore", "delete-new", "ignore-new", "kill-previous"] + }, + "opencodeIssueSessionStrategy": { + "type": "string", + "enum": ["reuse", "delete", "ignore"] + } + } + }, + "defaultPlugins": { + "type": "object", + "additionalProperties": false, + "properties": { + "runtime": { + "type": "string", + "default": "tmux", + "description": "Default runtime plugin name." + }, + "agent": { + "type": "string", + "default": "claude-code", + "description": "Default agent plugin name." + }, + "workspace": { + "type": "string", + "default": "worktree", + "description": "Default workspace plugin name." + }, + "notifiers": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "orchestrator": { + "$ref": "#/$defs/roleAgentDefaults" + }, + "worker": { + "$ref": "#/$defs/roleAgentDefaults" + } + } + }, + "installedPluginConfig": { + "type": "object", + "additionalProperties": false, + "required": ["name", "source"], + "properties": { + "name": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["registry", "npm", "local"] + }, + "package": { + "type": "string" + }, + "version": { + "type": "string" + }, + "path": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "default": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "source": { + "const": "local" + } + }, + "required": ["source"] + }, + "then": { + "required": ["path"] + } + }, + { + "if": { + "properties": { + "source": { + "enum": ["registry", "npm"] + } + }, + "required": ["source"] + }, + "then": { + "required": ["package"] + } + }, + { + "not": { + "required": ["package", "path"] + } + } + ] + }, + "powerConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "preventIdleSleep": { + "type": "boolean" + } + } + }, + "dashboardConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "attentionZones": { + "type": "string", + "enum": ["simple", "detailed"], + "default": "simple" + } + } + }, + "lifecycleConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "autoCleanupOnMerge": { + "type": "boolean", + "default": true + }, + "mergeCleanupIdleGraceMs": { + "oneOf": [ + { + "const": 0 + }, + { + "type": "integer", + "minimum": 10000 + } + ], + "default": 300000 + } + } + }, + "notificationRouting": { + "type": "object", + "default": {}, + "properties": { + "urgent": { + "type": "array", + "items": { + "type": "string" + } + }, + "action": { + "type": "array", + "items": { + "type": "string" + } + }, + "warning": { + "type": "array", + "items": { + "type": "string" + } + }, + "info": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } +}