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:
parent
a89c0529e6
commit
dcfb6fe8fd
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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}/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue