agent-relay-server 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +12 -14
  2. package/package.json +18 -1
  3. package/public/index.html +979 -2575
  4. package/public/manifest.webmanifest +6 -6
  5. package/public/sw.js +16 -10
  6. package/recipes/code-review.yaml +26 -0
  7. package/recipes/debug.yaml +20 -0
  8. package/recipes/feature.yaml +26 -0
  9. package/recipes/refactor.yaml +20 -0
  10. package/recipes/test.yaml +20 -0
  11. package/runner/src/adapter.ts +69 -0
  12. package/runner/src/config.ts +144 -0
  13. package/scripts/orchestrator-spawn-smoke.ts +2 -9
  14. package/src/agent-spawn.ts +2 -94
  15. package/src/automations.ts +774 -0
  16. package/src/bus-outbox.ts +75 -0
  17. package/src/bus.ts +439 -0
  18. package/src/cli.ts +251 -5
  19. package/src/commands-db.ts +160 -0
  20. package/src/config.ts +1 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +241 -34
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +94 -5
  26. package/src/recipe-db.ts +163 -0
  27. package/src/recipe-loader.ts +100 -0
  28. package/src/recipe-runner.ts +206 -0
  29. package/src/recipe-validator.ts +85 -0
  30. package/src/routes.ts +649 -155
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +42 -31
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -493
  35. package/src/upgrade.ts +14 -28
  36. package/public/dashboard/actions.js +0 -819
  37. package/public/dashboard/api.js +0 -336
  38. package/public/dashboard/app.js +0 -34
  39. package/public/dashboard/charts.js +0 -128
  40. package/public/dashboard/computed.js +0 -693
  41. package/public/dashboard/constants.js +0 -28
  42. package/public/dashboard/display.js +0 -345
  43. package/public/dashboard/state.js +0 -129
  44. package/public/dashboard/utils.js +0 -207
@@ -0,0 +1,163 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb } from "./db";
3
+ import type { RecipeAgentInstance, RecipeInstance } from "./types";
4
+
5
+ type RecipeInstanceStatus = RecipeInstance["status"];
6
+ type RecipeAgentStatus = RecipeAgentInstance["status"];
7
+
8
+ interface RecipeInstanceRow {
9
+ id: string;
10
+ recipe_name: string;
11
+ recipe_source: string;
12
+ cwd: string;
13
+ orchestrator_id: string;
14
+ status: RecipeInstanceStatus;
15
+ started_by: string;
16
+ error: string | null;
17
+ started_at: number;
18
+ stopped_at: number | null;
19
+ }
20
+
21
+ interface RecipeAgentInstanceRow {
22
+ instance_id: string;
23
+ role: string;
24
+ agent_id: string;
25
+ provider: string;
26
+ status: RecipeAgentStatus;
27
+ idx: number | null;
28
+ }
29
+
30
+ interface CreateRecipeInstanceInput {
31
+ recipeName: string;
32
+ recipeSource: RecipeInstance["recipeSource"];
33
+ cwd: string;
34
+ orchestratorId: string;
35
+ startedBy: string;
36
+ }
37
+
38
+ interface UpsertRecipeAgentInstanceInput {
39
+ instanceId: string;
40
+ role: string;
41
+ agentId: string;
42
+ provider: string;
43
+ status: RecipeAgentStatus;
44
+ index?: number;
45
+ }
46
+
47
+ export function createRecipeInstance(input: CreateRecipeInstanceInput): RecipeInstance {
48
+ const now = Date.now();
49
+ const id = randomUUID();
50
+ getDb().prepare(`
51
+ INSERT INTO recipe_instances (id, recipe_name, recipe_source, cwd, orchestrator_id, status, started_by, started_at)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
53
+ `).run(id, input.recipeName, input.recipeSource, input.cwd, input.orchestratorId, "starting", input.startedBy, now);
54
+ return getRecipeInstance(id)!;
55
+ }
56
+
57
+ export function listRecipeInstances(status?: RecipeInstanceStatus): RecipeInstance[] {
58
+ const rows = status
59
+ ? getDb().prepare("SELECT * FROM recipe_instances WHERE status = ? ORDER BY started_at DESC").all(status)
60
+ : getDb().prepare("SELECT * FROM recipe_instances ORDER BY started_at DESC").all();
61
+ return (rows as RecipeInstanceRow[]).map(rowToRecipeInstance);
62
+ }
63
+
64
+ export function getRecipeInstance(id: string): RecipeInstance | null {
65
+ const row = getDb().prepare("SELECT * FROM recipe_instances WHERE id = ?").get(id) as RecipeInstanceRow | undefined;
66
+ return row ? rowToRecipeInstance(row) : null;
67
+ }
68
+
69
+ export function updateRecipeInstance(
70
+ id: string,
71
+ input: Partial<Pick<RecipeInstance, "status" | "error" | "stoppedAt">>,
72
+ ): RecipeInstance | null {
73
+ const existing = getRecipeInstance(id);
74
+ if (!existing) return null;
75
+ getDb().prepare(`
76
+ UPDATE recipe_instances
77
+ SET status = ?, error = ?, stopped_at = ?
78
+ WHERE id = ?
79
+ `).run(
80
+ input.status ?? existing.status,
81
+ input.error ?? existing.error ?? null,
82
+ input.stoppedAt ?? existing.stoppedAt ?? null,
83
+ id,
84
+ );
85
+ return getRecipeInstance(id);
86
+ }
87
+
88
+ export function upsertRecipeAgentInstance(input: UpsertRecipeAgentInstanceInput): RecipeAgentInstance {
89
+ getDb().prepare(`
90
+ INSERT INTO recipe_agent_instances (instance_id, role, agent_id, provider, status, idx)
91
+ VALUES (?, ?, ?, ?, ?, ?)
92
+ ON CONFLICT(instance_id, role, agent_id) DO UPDATE SET
93
+ provider = excluded.provider,
94
+ status = excluded.status,
95
+ idx = excluded.idx
96
+ `).run(input.instanceId, input.role, input.agentId, input.provider, input.status, input.index ?? null);
97
+ return listRecipeAgentInstances(input.instanceId)
98
+ .find((agent) => agent.role === input.role && agent.agentId === input.agentId)!;
99
+ }
100
+
101
+ export function updateRecipeAgentInstance(
102
+ instanceId: string,
103
+ agentId: string,
104
+ status: RecipeAgentStatus,
105
+ ): RecipeAgentInstance | null {
106
+ const changed = getDb().prepare(`
107
+ UPDATE recipe_agent_instances
108
+ SET status = ?
109
+ WHERE instance_id = ? AND agent_id = ?
110
+ `).run(status, instanceId, agentId).changes;
111
+ if (!changed) return null;
112
+ return listRecipeAgentInstances(instanceId).find((agent) => agent.agentId === agentId) ?? null;
113
+ }
114
+
115
+ export function replaceRecipeAgentInstanceAgentId(
116
+ instanceId: string,
117
+ currentAgentId: string,
118
+ nextAgentId: string,
119
+ status: RecipeAgentStatus,
120
+ ): RecipeAgentInstance | null {
121
+ const changed = getDb().prepare(`
122
+ UPDATE recipe_agent_instances
123
+ SET agent_id = ?, status = ?
124
+ WHERE instance_id = ? AND agent_id = ?
125
+ `).run(nextAgentId, status, instanceId, currentAgentId).changes;
126
+ if (!changed) return null;
127
+ return listRecipeAgentInstances(instanceId).find((agent) => agent.agentId === nextAgentId) ?? null;
128
+ }
129
+
130
+ function listRecipeAgentInstances(instanceId: string): RecipeAgentInstance[] {
131
+ const rows = getDb().prepare(`
132
+ SELECT * FROM recipe_agent_instances
133
+ WHERE instance_id = ?
134
+ ORDER BY role ASC, idx ASC, agent_id ASC
135
+ `).all(instanceId) as RecipeAgentInstanceRow[];
136
+ return rows.map(rowToRecipeAgentInstance);
137
+ }
138
+
139
+ function rowToRecipeInstance(row: RecipeInstanceRow): RecipeInstance {
140
+ return {
141
+ id: row.id,
142
+ recipeName: row.recipe_name,
143
+ recipeSource: row.recipe_source === "user" ? "user" : "builtin",
144
+ cwd: row.cwd,
145
+ orchestratorId: row.orchestrator_id,
146
+ status: row.status,
147
+ startedBy: row.started_by,
148
+ error: row.error ?? undefined,
149
+ startedAt: row.started_at,
150
+ stoppedAt: row.stopped_at ?? undefined,
151
+ agents: listRecipeAgentInstances(row.id),
152
+ };
153
+ }
154
+
155
+ function rowToRecipeAgentInstance(row: RecipeAgentInstanceRow): RecipeAgentInstance {
156
+ return {
157
+ role: row.role,
158
+ agentId: row.agent_id,
159
+ provider: row.provider,
160
+ status: row.status,
161
+ index: row.idx ?? undefined,
162
+ };
163
+ }
@@ -0,0 +1,100 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { validateRecipe } from "./recipe-validator";
4
+ import type { Recipe } from "./types";
5
+
6
+ interface LoadedRecipe {
7
+ name: string;
8
+ source: "builtin" | "user";
9
+ path: string;
10
+ recipe: Recipe;
11
+ raw: string;
12
+ }
13
+
14
+ export function listRecipes(): LoadedRecipe[] {
15
+ const byName = new Map<string, LoadedRecipe>();
16
+ for (const recipe of loadRecipeDir(builtinRecipeDir(), "builtin")) byName.set(recipe.name, recipe);
17
+ for (const recipe of loadRecipeDir(userRecipeDir(), "user")) byName.set(recipe.name, recipe);
18
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
19
+ }
20
+
21
+ export function getRecipe(name: string): LoadedRecipe | null {
22
+ return listRecipes().find((recipe) => recipe.name === name || slug(recipe.recipe.name) === name) ?? null;
23
+ }
24
+
25
+ function builtinRecipeDir(): string {
26
+ return resolve(import.meta.dir, "../recipes");
27
+ }
28
+
29
+ function userRecipeDir(): string {
30
+ return join(process.env.HOME || ".", ".agent-relay", "recipes");
31
+ }
32
+
33
+ function loadRecipeDir(dir: string, source: LoadedRecipe["source"]): LoadedRecipe[] {
34
+ if (!existsSync(dir)) return [];
35
+ return readdirSync(dir)
36
+ .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml"))
37
+ .map((file) => loadRecipeFile(join(dir, file), source));
38
+ }
39
+
40
+ function loadRecipeFile(path: string, source: LoadedRecipe["source"]): LoadedRecipe {
41
+ const raw = readFileSync(path, "utf8");
42
+ const recipe = validateRecipe(parseSimpleYaml(raw));
43
+ return { name: slug(basename(path).replace(/\.ya?ml$/, "")), source, path, recipe, raw };
44
+ }
45
+
46
+ function slug(value: string): string {
47
+ return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
48
+ }
49
+
50
+ function parseSimpleYaml(raw: string): Record<string, unknown> {
51
+ const root: Record<string, unknown> = {};
52
+ let section: string | null = null;
53
+ let currentAgent: Record<string, unknown> | null = null;
54
+
55
+ for (const original of raw.split(/\r?\n/)) {
56
+ const line = original.replace(/\s+#.*$/, "");
57
+ if (!line.trim()) continue;
58
+ if (!line.startsWith(" ")) {
59
+ currentAgent = null;
60
+ const [key, ...rest] = line.split(":");
61
+ section = (key ?? "").trim();
62
+ const value = rest.join(":").trim();
63
+ if (value) root[section] = parseScalar(value);
64
+ else if (section === "agents") root.agents = [];
65
+ else root[section] = {};
66
+ continue;
67
+ }
68
+ if (section === "agents") {
69
+ const trimmed = line.trim();
70
+ if (trimmed.startsWith("- ")) {
71
+ currentAgent = {};
72
+ (root.agents as unknown[]).push(currentAgent);
73
+ assign(currentAgent, trimmed.slice(2));
74
+ } else if (currentAgent) {
75
+ assign(currentAgent, trimmed);
76
+ }
77
+ continue;
78
+ }
79
+ if (section && typeof root[section] === "object" && root[section] !== null) {
80
+ assign(root[section] as Record<string, unknown>, line.trim());
81
+ }
82
+ }
83
+ return root;
84
+ }
85
+
86
+ function assign(target: Record<string, unknown>, assignment: string): void {
87
+ const [key, ...rest] = assignment.split(":");
88
+ if (!key) return;
89
+ target[key.trim()] = parseScalar(rest.join(":").trim());
90
+ }
91
+
92
+ function parseScalar(value: string): unknown {
93
+ const trimmed = value.trim();
94
+ if (!trimmed) return "";
95
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
96
+ return trimmed.slice(1, -1).split(",").map((item) => String(parseScalar(item.trim())));
97
+ }
98
+ if (/^\d+$/.test(trimmed)) return Number(trimmed);
99
+ return trimmed.replace(/^["']|["']$/g, "");
100
+ }
@@ -0,0 +1,206 @@
1
+ import { createCommand } from "./commands-db";
2
+ import { listOrchestrators } from "./db";
3
+ import { getRecipe } from "./recipe-loader";
4
+ import {
5
+ createRecipeInstance,
6
+ getRecipeInstance,
7
+ listRecipeInstances,
8
+ replaceRecipeAgentInstanceAgentId,
9
+ updateRecipeAgentInstance,
10
+ updateRecipeInstance,
11
+ upsertRecipeAgentInstance,
12
+ } from "./recipe-db";
13
+ import type { Command, RecipeInstance } from "./types";
14
+
15
+ interface StartRecipeInput {
16
+ name: string;
17
+ cwd?: string;
18
+ orchestratorId?: string;
19
+ startedBy?: string;
20
+ }
21
+
22
+ interface StartRecipeResult {
23
+ instance: RecipeInstance;
24
+ commands: Command[];
25
+ }
26
+
27
+ interface StopRecipeResult {
28
+ instance: RecipeInstance;
29
+ commands: Command[];
30
+ }
31
+
32
+ export function startRecipe(input: StartRecipeInput): StartRecipeResult {
33
+ const loaded = getRecipe(input.name);
34
+ if (!loaded) throw new Error(`recipe not found: ${input.name}`);
35
+ const recipe = loaded.recipe;
36
+ const orchestrator = selectOrchestrator(input.orchestratorId, recipe.agents.map((agent) => agent.provider));
37
+ if (!orchestrator) throw new Error("no online orchestrator supports this recipe");
38
+ const cwd = input.cwd ?? orchestrator.baseDir;
39
+ const instance = createRecipeInstance({
40
+ recipeName: loaded.name,
41
+ recipeSource: loaded.source,
42
+ cwd,
43
+ orchestratorId: orchestrator.id,
44
+ startedBy: input.startedBy ?? "cli",
45
+ });
46
+
47
+ const commands: Command[] = [];
48
+ for (const agent of recipe.agents) {
49
+ const count = agent.count ?? 1;
50
+ for (let index = 0; index < count; index++) {
51
+ const label = count > 1 ? `${recipe.name}:${agent.role}:${index + 1}` : `${recipe.name}:${agent.role}`;
52
+ const command = createCommand({
53
+ type: "agent.spawn",
54
+ source: instance.startedBy,
55
+ target: orchestrator.agentId,
56
+ params: {
57
+ provider: agent.provider,
58
+ role: agent.role,
59
+ label,
60
+ cwd,
61
+ prompt: agent.prompt,
62
+ approvalMode: agent.approvalMode,
63
+ recipe: loaded.name,
64
+ recipeInstanceId: instance.id,
65
+ tags: ["recipe", `recipe:${loaded.name}`, `role:${agent.role}`],
66
+ env: agent.env ?? {},
67
+ },
68
+ ttlMs: 60_000,
69
+ });
70
+ commands.push(command);
71
+ upsertRecipeAgentInstance({
72
+ instanceId: instance.id,
73
+ role: agent.role,
74
+ agentId: `pending:${command.id}`,
75
+ provider: agent.provider,
76
+ status: "spawning",
77
+ index,
78
+ });
79
+ }
80
+ }
81
+
82
+ return { instance: getRecipeInstance(instance.id)!, commands };
83
+ }
84
+
85
+ export function stopRecipe(instanceId: string, stoppedBy = "cli"): StopRecipeResult {
86
+ const instance = getRecipeInstance(instanceId);
87
+ if (!instance) throw new Error("recipe instance not found");
88
+ if (instance.status === "stopped") return { instance, commands: [] };
89
+
90
+ updateRecipeInstance(instance.id, { status: "stopping" });
91
+ const commands: Command[] = [];
92
+ for (const agent of instance.agents) {
93
+ if (agent.status === "stopped") continue;
94
+ if (agent.agentId.startsWith("pending:")) {
95
+ updateRecipeAgentInstance(instance.id, agent.agentId, "stopped");
96
+ continue;
97
+ }
98
+ const command = createCommand({
99
+ type: "agent.shutdown",
100
+ source: stoppedBy,
101
+ target: agent.agentId,
102
+ params: { recipeInstanceId: instance.id, role: agent.role },
103
+ ttlMs: 30_000,
104
+ });
105
+ commands.push(command);
106
+ updateRecipeAgentInstance(instance.id, agent.agentId, "stopping");
107
+ }
108
+
109
+ const next = commands.length === 0
110
+ ? updateRecipeInstance(instance.id, { status: "stopped", stoppedAt: Date.now() })!
111
+ : getRecipeInstance(instance.id)!;
112
+ return { instance: next, commands };
113
+ }
114
+
115
+ export { getRecipeInstance, listRecipeInstances };
116
+
117
+ export function applyCommandToRecipe(command: Command): RecipeInstance | null {
118
+ const instanceId = stringParam(command.params, "recipeInstanceId");
119
+ if (!instanceId) return null;
120
+ const instance = getRecipeInstance(instanceId);
121
+ if (!instance) return null;
122
+
123
+ if (command.type === "agent.spawn") {
124
+ const pendingAgentId = `pending:${command.id}`;
125
+ if (command.status === "succeeded") {
126
+ const agentId = commandResultAgentId(command.result);
127
+ if (!agentId) return instance;
128
+ replaceRecipeAgentInstanceAgentId(instance.id, pendingAgentId, agentId, "running");
129
+ return refreshRecipeInstanceStatus(instance.id);
130
+ }
131
+ if (isFailedCommandStatus(command.status)) {
132
+ updateRecipeAgentInstance(instance.id, pendingAgentId, "failed");
133
+ return updateRecipeInstance(instance.id, {
134
+ status: "failed",
135
+ error: command.error ?? `${command.type} ${command.status}`,
136
+ });
137
+ }
138
+ return instance;
139
+ }
140
+
141
+ if (command.type === "agent.shutdown") {
142
+ if (command.status === "succeeded") {
143
+ updateRecipeAgentInstance(instance.id, command.target, "stopped");
144
+ return refreshRecipeInstanceStatus(instance.id);
145
+ }
146
+ if (isFailedCommandStatus(command.status)) {
147
+ updateRecipeAgentInstance(instance.id, command.target, "failed");
148
+ return updateRecipeInstance(instance.id, {
149
+ status: "failed",
150
+ error: command.error ?? `${command.type} ${command.status}`,
151
+ });
152
+ }
153
+ }
154
+
155
+ return instance;
156
+ }
157
+
158
+ function selectOrchestrator(id: string | undefined, providers: string[]) {
159
+ const required = new Set(providers);
160
+ const candidates = listOrchestrators()
161
+ .filter((orchestrator) => orchestrator.status === "online")
162
+ .filter((orchestrator) => !id || orchestrator.id === id)
163
+ .filter((orchestrator) => [...required].every((provider) => orchestrator.providers.includes(provider as any)));
164
+ return candidates[0] ?? null;
165
+ }
166
+
167
+ function refreshRecipeInstanceStatus(instanceId: string): RecipeInstance | null {
168
+ const instance = getRecipeInstance(instanceId);
169
+ if (!instance) return null;
170
+ if (instance.agents.some((agent) => agent.status === "failed")) {
171
+ return updateRecipeInstance(instance.id, { status: "failed" });
172
+ }
173
+ if (instance.agents.length > 0 && instance.agents.every((agent) => agent.status === "stopped")) {
174
+ return updateRecipeInstance(instance.id, { status: "stopped", stoppedAt: Date.now() });
175
+ }
176
+ if (instance.agents.length > 0 && instance.agents.every((agent) => agent.status === "running")) {
177
+ return updateRecipeInstance(instance.id, { status: "running" });
178
+ }
179
+ if (instance.status === "stopping") return instance;
180
+ return updateRecipeInstance(instance.id, { status: "starting" });
181
+ }
182
+
183
+ function commandResultAgentId(result: Record<string, unknown> | undefined): string | null {
184
+ if (!result) return null;
185
+ if (typeof result.agentId === "string" && result.agentId.trim()) return result.agentId.trim();
186
+ const agent = result.agent;
187
+ if (agent && typeof agent === "object" && !Array.isArray(agent)) {
188
+ const id = (agent as Record<string, unknown>).id;
189
+ if (typeof id === "string" && id.trim()) return id.trim();
190
+ }
191
+ const managedAgent = result.managedAgent;
192
+ if (managedAgent && typeof managedAgent === "object" && !Array.isArray(managedAgent)) {
193
+ const id = (managedAgent as Record<string, unknown>).agentId;
194
+ if (typeof id === "string" && id.trim()) return id.trim();
195
+ }
196
+ return null;
197
+ }
198
+
199
+ function stringParam(params: Record<string, unknown>, key: string): string | null {
200
+ const value = params[key];
201
+ return typeof value === "string" && value.trim() ? value.trim() : null;
202
+ }
203
+
204
+ function isFailedCommandStatus(status: Command["status"]): boolean {
205
+ return status === "failed" || status === "timed_out" || status === "rejected" || status === "canceled";
206
+ }
@@ -0,0 +1,85 @@
1
+ import type { Recipe, RecipeAgent } from "./types";
2
+
3
+ class RecipeValidationError extends Error {}
4
+
5
+ export function validateRecipe(value: unknown): Recipe {
6
+ if (!isRecord(value)) throw new RecipeValidationError("recipe must be an object");
7
+ const name = requiredString(value.name, "name");
8
+ const description = requiredString(value.description, "description");
9
+ if (!Array.isArray(value.agents) || value.agents.length === 0) {
10
+ throw new RecipeValidationError("agents must be a non-empty array");
11
+ }
12
+ const agents = value.agents.map(validateAgent);
13
+ const roles = new Set<string>();
14
+ for (const agent of agents) {
15
+ if (roles.has(agent.role)) throw new RecipeValidationError(`duplicate recipe agent role: ${agent.role}`);
16
+ roles.add(agent.role);
17
+ }
18
+ return {
19
+ name,
20
+ description,
21
+ version: optionalString(value.version, "version"),
22
+ author: optionalString(value.author, "author"),
23
+ agents,
24
+ workflow: isRecord(value.workflow) ? {
25
+ trigger: optionalString(value.workflow.trigger, "workflow.trigger"),
26
+ fanOut: value.workflow.fanOut === "first" ? "first" : value.workflow.fanOut === "all" ? "all" : undefined,
27
+ collect: optionalString(value.workflow.collect, "workflow.collect"),
28
+ routing: undefined,
29
+ } : undefined,
30
+ lifecycle: isRecord(value.lifecycle) ? {
31
+ mode: value.lifecycle.mode === "persistent" ? "persistent" : "ephemeral",
32
+ idleTimeoutMs: optionalPositiveInteger(value.lifecycle.idleTimeoutMs, "lifecycle.idleTimeoutMs"),
33
+ } : undefined,
34
+ };
35
+ }
36
+
37
+ function validateAgent(value: unknown): RecipeAgent {
38
+ if (!isRecord(value)) throw new RecipeValidationError("agent must be an object");
39
+ const provider = value.provider;
40
+ if (provider !== "claude" && provider !== "codex") throw new RecipeValidationError("agent.provider must be claude or codex");
41
+ const capabilities = stringArray(value.capabilities, "agent.capabilities");
42
+ if (capabilities.length === 0) throw new RecipeValidationError("agent.capabilities must not be empty");
43
+ const approvalMode = value.approvalMode;
44
+ if (approvalMode !== undefined && approvalMode !== "open" && approvalMode !== "guarded" && approvalMode !== "read-only") {
45
+ throw new RecipeValidationError("agent.approvalMode must be open, guarded, or read-only");
46
+ }
47
+ return {
48
+ role: requiredString(value.role, "agent.role"),
49
+ provider,
50
+ count: optionalPositiveInteger(value.count, "agent.count"),
51
+ capabilities,
52
+ label: optionalString(value.label, "agent.label"),
53
+ tags: value.tags === undefined ? undefined : stringArray(value.tags, "agent.tags"),
54
+ approvalMode,
55
+ prompt: optionalString(value.prompt, "agent.prompt"),
56
+ model: optionalString(value.model, "agent.model"),
57
+ env: isRecord(value.env) ? Object.fromEntries(Object.entries(value.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")) : undefined,
58
+ };
59
+ }
60
+
61
+ function requiredString(value: unknown, field: string): string {
62
+ if (typeof value !== "string" || !value.trim()) throw new RecipeValidationError(`${field} required`);
63
+ return value.trim();
64
+ }
65
+
66
+ function optionalString(value: unknown, field: string): string | undefined {
67
+ if (value === undefined || value === null) return undefined;
68
+ if (typeof value !== "string") throw new RecipeValidationError(`${field} must be a string`);
69
+ return value.trim() || undefined;
70
+ }
71
+
72
+ function stringArray(value: unknown, field: string): string[] {
73
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new RecipeValidationError(`${field} must be an array of strings`);
74
+ return value.map((item) => item.trim()).filter(Boolean);
75
+ }
76
+
77
+ function optionalPositiveInteger(value: unknown, field: string): number | undefined {
78
+ if (value === undefined || value === null) return undefined;
79
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new RecipeValidationError(`${field} must be a positive integer`);
80
+ return value;
81
+ }
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
85
+ }