feat: add $schema support to agent-orchestrator.yaml (#1373)

* feat: add config schema support for agent-orchestrator.yaml (#1370)

Expose a committed JSON Schema and inject the canonical $schema URL into generated and updated configs so editors can autocomplete and validate AO config files.

* fix schema injection edge cases and shared constant

* fix: address config schema review feedback (#1370)
This commit is contained in:
yyovil 2026-04-26 19:42:45 +05:30 committed by GitHub
parent a89c0529e6
commit dcfb6fe8fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1136 additions and 339 deletions

View File

@ -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
```

View File

@ -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}/

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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<Record<string, string>>;

View File

@ -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(`

View File

@ -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([]);
});
});

View File

@ -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<string> {
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 <name>", "Display/plugin name")
.option("--slot <slot>", "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal")
.option(
"--slot <slot>",
"Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal",
)
.option("--description <description>", "Short plugin description")
.option("--author <author>", "Package author")
.option("--package-name <packageName>", "npm package name")
@ -483,8 +503,14 @@ export function registerPlugin(program: Command): void {
.command("install")
.description("Install a plugin into the current config")
.argument("<reference>", "Marketplace id, package name, or local path")
.option("--url <url>", "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)")
.option("--token <token>", "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)")
.option(
"--url <url>",
"OpenClaw webhook URL (passed to setup when installing notifier-openclaw)",
)
.option(
"--token <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) {

View File

@ -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 <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);

View File

@ -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<De
if (available.length > 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<OrchestratorConfig>
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<OrchestratorConfig>
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<string, unknown>));
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<void> {
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<void> {
async function warnAboutOpenClawStatus(config: OrchestratorConfig): Promise<void> {
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<string, Record<string, unknown>>).map(
(p) => p.sessionPrefix as string,
).filter(Boolean),
Object.values(rawConfig.projects as Record<string, Record<string, unknown>>)
.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<string, unknown>);
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<string, unknown>));
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);
}
});
}

View File

@ -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}/

View File

@ -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(

View File

@ -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<string, { type?: string; format?: string }>;
};
};
};
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
// =============================================================================

View File

@ -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 = {

View File

@ -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<T extends Record<string, unknown>>(
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<st
projectConfig.postCreate = [installCmd];
}
return {
return withConfigSchema({
port,
defaults: {
runtime: "tmux",
@ -269,14 +291,14 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record<st
projects: {
[projectId]: projectConfig,
},
};
});
}
/**
* Serialize a config object to YAML string.
*/
export function configToYaml(config: Record<string, unknown>): 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);

View File

@ -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<string, unknown>)
return parsed && typeof parsed === "object" && "projects" in (parsed as Record<string, unknown>)
? "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<string, unknown>;
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<string, unknown>).map(([projectId, value]) => {
if (!value || typeof value !== "object") {
return [projectId, value];
}
Object.entries(parsedObject["projects"] as Record<string, unknown>).map(
([projectId, value]) => {
if (!value || typeof value !== "object") {
return [projectId, value];
}
const project = value as Record<string, unknown>;
if (typeof project["storageKey"] === "string" || typeof project["path"] !== "string") {
return [projectId, value];
}
const project = value as Record<string, unknown>;
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;

View File

@ -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<string, Record<string, unknown>>;
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<typeof GlobalRepoIdentitySchema> | undefined {
function normalizeRepoIdentity(
originUrl: string | null,
): z.infer<typeof GlobalRepoIdentitySchema> | undefined {
if (!originUrl) return undefined;
const normalizedOriginUrl = normalizeOriginUrl(originUrl);
@ -665,7 +680,9 @@ function ensureProjectStorageIdentity(
globalConfigPath?: string,
alreadyLocked = false,
): (GlobalProjectEntry & Record<string, unknown>) | null {
const entry = globalConfig.projects[projectId] as (GlobalProjectEntry & Record<string, unknown>) | undefined;
const entry = globalConfig.projects[projectId] as
| (GlobalProjectEntry & Record<string, unknown>)
| 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<string, unknown>) | undefined;
const freshEntry = fresh.projects[projectId] as
| (GlobalProjectEntry & Record<string, unknown>)
| 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<string, unknown>)
@ -872,16 +893,18 @@ export function resolveProjectIdentity(
projectId: string,
globalConfig: GlobalConfig,
globalConfigPath?: string,
): (Record<string, unknown> & {
name: string;
path: string;
storageKey: string;
originUrl?: string;
repo?: string;
defaultBranch: string;
sessionPrefix: string;
resolveError?: string;
}) | null {
):
| (Record<string, unknown> & {
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<string, unknown>)
| 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<string, unknown>) | undefined;
const globalConfig =
loadGlobalConfig(configPath, { alreadyLocked: true }) ?? makeEmptyGlobalConfig();
const entry = globalConfig.projects[projectId] as
| (GlobalProjectEntry & Record<string, unknown>)
| 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<string, unknown>;
return Object.values(projects).some(
(entry) =>
entry !== null && entry !== undefined && typeof entry === "object" && "path" in (entry as Record<string, unknown>),
entry !== null &&
entry !== undefined &&
typeof entry === "object" &&
"path" in (entry as Record<string, unknown>),
);
}
@ -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<string, unknown>,
): RawGlobalConfigSanitization {
function sanitizeRawGlobalConfig(raw: Record<string, unknown>): RawGlobalConfigSanitization {
const projects = raw["projects"];
if (!projects || typeof projects !== "object") {
return { changed: false, strippedProjects: [] };

View File

@ -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,

View File

@ -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.

553
schema/config.schema.json Normal file
View File

@ -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"
}
}
}
}
}