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
package/src/routes.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  reapStaleAgents,
54
54
  reapStaleOrchestrators,
55
55
  releaseExpiredClaims,
56
+ releaseOrphanedTasks,
56
57
  validateAgentSession,
57
58
  upsertOrchestrator,
58
59
  getOrchestrator,
@@ -63,6 +64,10 @@ import {
63
64
  evaluatePoolBindings,
64
65
  ValidationError,
65
66
  } from "./db";
67
+ import { createCommand, deleteCommand, expireCommands, getCommand, listCommands, updateCommand } from "./commands-db";
68
+ import { getRecipe, listRecipes } from "./recipe-loader";
69
+ import { applyCommandToRecipe, getRecipeInstance, listRecipeInstances, startRecipe, stopRecipe } from "./recipe-runner";
70
+ import { createToken, getToken, listTokens, revokeToken } from "./token-db";
66
71
  import {
67
72
  getConnector,
68
73
  listConnectors,
@@ -71,9 +76,21 @@ import {
71
76
  runConnectorAction,
72
77
  writeConnectorConfig,
73
78
  } from "./connectors";
74
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
79
+ import {
80
+ createAutomation,
81
+ deleteAutomation,
82
+ getAutomation,
83
+ listAutomationRuns,
84
+ listAutomations,
85
+ runAutomationNow,
86
+ updateAutomation,
87
+ type AutomationDispatchResult,
88
+ } from "./automations";
89
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentSessionGuard, ChannelBinding, ChannelBindingMode, ChannelDirection, ChannelRouteTarget, ChannelSummary, Command, CommandStatus, CreateCommandInput, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, OrchestratorRuntimeInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
75
90
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
76
- import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
91
+ import { listHostDirectories } from "./agent-spawn";
92
+ import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
93
+ import type { ProviderConfig } from "../runner/src/adapter";
77
94
  import {
78
95
  getIntegrationAuth,
79
96
  hasIntegrationScope,
@@ -93,6 +110,7 @@ import {
93
110
  emitOrchestratorRemoved,
94
111
  emitPoolBindingChanged,
95
112
  } from "./sse";
113
+ import { emitRelayEvent } from "./events";
96
114
 
97
115
  type Handler = (
98
116
  req: Request,
@@ -175,16 +193,17 @@ function parseQueryInt(
175
193
  return n;
176
194
  }
177
195
 
178
- const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
196
+ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
179
197
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
180
198
  const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool"] as const;
181
- const VALID_CHANNEL_BINDING_MODES = ["exclusive", "claimable", "broadcast"] as const;
199
+ const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
182
200
  const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
183
201
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
184
202
  const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
185
203
  const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
186
204
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
187
- const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
205
+ const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
206
+ const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
188
207
  const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
189
208
  const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state"] as const;
190
209
  const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
@@ -232,6 +251,13 @@ function cleanMeta(value: unknown): Record<string, unknown> | undefined {
232
251
  return value;
233
252
  }
234
253
 
254
+ function cleanParams(value: unknown, field = "params"): Record<string, unknown> | undefined {
255
+ if (value === undefined || value === null) return undefined;
256
+ if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
257
+ if (JSON.stringify(value).length > 65_536) throw new ValidationError(`${field} is too large`);
258
+ return value;
259
+ }
260
+
235
261
  function cleanEnum<T extends readonly string[]>(
236
262
  value: unknown,
237
263
  field: string,
@@ -357,6 +383,11 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
357
383
  return input;
358
384
  }
359
385
 
386
+ const FANOUT_PREFIXES = ["tag:", "cap:", "label:"];
387
+ function isDirectTarget(to: string): boolean {
388
+ return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
389
+ }
390
+
360
391
  function applyReplyRouting(input: SendMessageInput): void {
361
392
  if (input.to || !input.replyTo) return;
362
393
  const parent = getMessage(input.replyTo);
@@ -557,6 +588,53 @@ function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
557
588
  };
558
589
  }
559
590
 
591
+ function channelIdFromIntegrationTarget(target: string): string | undefined {
592
+ if (!target.startsWith("channel:")) return undefined;
593
+ const channelId = target.slice("channel:".length).trim();
594
+ if (!channelId) throw new ValidationError("channel target id required");
595
+ return channelId;
596
+ }
597
+
598
+ function metadataWithResolvedChannelTarget(
599
+ metadata: Record<string, unknown> | undefined,
600
+ originalTarget: string,
601
+ channelId: string,
602
+ binding: ChannelBinding,
603
+ ): Record<string, unknown> {
604
+ return {
605
+ ...(metadata ?? {}),
606
+ relayRequestedTarget: originalTarget,
607
+ relayResolvedChannelId: channelId,
608
+ relayResolvedChannelBindingId: binding.id,
609
+ };
610
+ }
611
+
612
+ function resolveIntegrationEventTarget(input: IntegrationEventInput): IntegrationEventInput {
613
+ const channelId = channelIdFromIntegrationTarget(input.target);
614
+ if (!channelId) return input;
615
+
616
+ evaluatePoolBindings();
617
+ const channel = getChannel(channelId);
618
+ if (!channel) throw new ValidationError(`channel ${channelId} not found`);
619
+
620
+ const bindings = resolveChannelRoutes(channelId);
621
+ if (bindings.length === 0) throw new ValidationError(`channel ${channelId} has no binding`);
622
+ if (bindings.length > 1) throw new ValidationError(`channel ${channelId} has multiple bindings; integration channel targets must resolve to one backend agent`);
623
+
624
+ const binding = bindings[0]!;
625
+ const resolvedTarget = messageTargetForChannelTarget(binding.target, binding);
626
+ if (!getAgent(resolvedTarget)) {
627
+ throw new ValidationError(`channel ${channelId} binding resolves to ${resolvedTarget}; integration channel targets must resolve to one backend agent`);
628
+ }
629
+
630
+ return {
631
+ ...input,
632
+ target: resolvedTarget,
633
+ channel: input.channel ?? channelId,
634
+ metadata: metadataWithResolvedChannelTarget(input.metadata, input.target, channelId, binding),
635
+ };
636
+ }
637
+
560
638
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
561
639
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
562
640
  const status = cleanEnum(body.status, "status", VALID_TASK_STATUSES);
@@ -761,10 +839,11 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
761
839
  async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
762
840
  const task = getTask(taskId);
763
841
  if (!task) return;
842
+ const requestedTarget = typeof task.metadata?.relayRequestedTarget === "string" ? task.metadata.relayRequestedTarget : undefined;
764
843
  const integrations = getIntegrationTokens()
765
844
  .filter((integration) => integration.name === task.source)
766
845
  .filter((integration) => integration.callbackUrl)
767
- .filter((integration) => !integration.targets?.length || integration.targets.includes(task.target))
846
+ .filter((integration) => !integration.targets?.length || integration.targets.includes(task.target) || Boolean(requestedTarget && integration.targets.includes(requestedTarget)))
768
847
  .filter((integration) => !integration.channels?.length || !task.channel || integration.channels.includes(task.channel));
769
848
 
770
849
  for (const integration of integrations) {
@@ -832,15 +911,6 @@ function auditAgentStateTransition(agentId: string, before: AgentCard | null | u
832
911
  });
833
912
  }
834
913
 
835
- function orchestratorControlMeta(control: Record<string, unknown>): Record<string, unknown> {
836
- return {
837
- delivery: "interrupt",
838
- priority: "urgent",
839
- // Legacy orchestrators read control details from message meta.
840
- orchestratorControl: control,
841
- };
842
- }
843
-
844
914
  // --- Agent routes ---
845
915
 
846
916
  const postAgent: Handler = async (req) => {
@@ -896,14 +966,14 @@ const patchAgentStatus: Handler = async (req, params) => {
896
966
  if (!parsed.ok) return error(parsed.error, parsed.status);
897
967
  const body = parsed.body;
898
968
  if (!body?.status) return error("status required");
899
- const valid = ["online", "idle", "busy", "offline"];
900
- if (!valid.includes(body.status)) return error(`status must be one of: ${valid.join(", ")}`);
969
+ const valid = VALID_AGENT_STATUSES;
970
+ if (!valid.includes(body.status as any)) return error(`status must be one of: ${valid.join(", ")}`);
901
971
  try {
902
972
  const guard = normalizeAgentSessionGuard(req, body);
903
973
  const session = validateAgentSession(params.id!, guard);
904
974
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
905
975
  const before = getAgent(params.id!);
906
- if (!setStatus(params.id!, body.status as any, guard)) return error("agent not found", 404);
976
+ if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
907
977
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
908
978
  } catch (e) {
909
979
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -932,8 +1002,23 @@ const patchAgentLabel: Handler = async (req, params) => {
932
1002
  if (!parsed.ok) return error(parsed.error, parsed.status);
933
1003
  const body = parsed.body;
934
1004
  if (body === null || !("label" in body)) return error("label field required (string or null)");
1005
+ const before = getAgent(params.id!);
935
1006
  if (!setLabel(params.id!, body.label)) return error("agent not found", 404);
936
1007
  emitAgentStatus(params.id!);
1008
+ const after = getAgent(params.id!);
1009
+ if ((before?.label ?? null) !== (after?.label ?? null)) {
1010
+ auditEvent({
1011
+ clientId: "server-agent-" + params.id! + "-label-" + Date.now(),
1012
+ kind: "state",
1013
+ title: after?.label ? "Agent label set" : "Agent label cleared",
1014
+ body: after?.label ?? before?.label ?? "",
1015
+ meta: params.id!,
1016
+ icon: "ti-tag",
1017
+ view: "chat",
1018
+ agentId: params.id!,
1019
+ metadata: { field: "label", previous: before?.label ?? null, next: after?.label ?? null },
1020
+ });
1021
+ }
937
1022
  return json({ ok: true });
938
1023
  };
939
1024
 
@@ -943,9 +1028,23 @@ const patchAgentTags: Handler = async (req, params) => {
943
1028
  try {
944
1029
  const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
945
1030
  if (!tags) return error("tags field required");
1031
+ const before = getAgent(params.id!);
946
1032
  const agent = setTags(params.id!, tags);
947
1033
  if (!agent) return error("agent not found", 404);
948
1034
  emitAgentStatus(params.id!);
1035
+ if (JSON.stringify(before?.tags ?? []) !== JSON.stringify(agent.tags ?? [])) {
1036
+ auditEvent({
1037
+ clientId: "server-agent-" + params.id! + "-tags-" + Date.now(),
1038
+ kind: "state",
1039
+ title: "Agent tags updated",
1040
+ body: (agent.tags || []).join(", "),
1041
+ meta: params.id!,
1042
+ icon: "ti-tags",
1043
+ view: "chat",
1044
+ agentId: params.id!,
1045
+ metadata: { field: "tags", previous: before?.tags ?? [], next: agent.tags ?? [] },
1046
+ });
1047
+ }
949
1048
  return json(agent);
950
1049
  } catch (e) {
951
1050
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -987,7 +1086,8 @@ function agentCanReceiveControlAction(agent: AgentCard): boolean {
987
1086
  return agent.id !== "user" &&
988
1087
  agent.id !== "system" &&
989
1088
  agent.meta?.kind !== "channel" &&
990
- !agent.tags.includes("channel");
1089
+ !agent.tags.includes("channel") &&
1090
+ agent.meta?.runnerManaged === true;
991
1091
  }
992
1092
 
993
1093
  const postAgentAction: Handler = async (req, params) => {
@@ -1002,40 +1102,25 @@ const postAgentAction: Handler = async (req, params) => {
1002
1102
  if (!agentCanReceiveControlAction(agent)) return error("agent does not support dashboard control actions", 400);
1003
1103
 
1004
1104
  const title = action === "restart" ? "Agent restart requested" : "Agent shutdown requested";
1005
- const msg = sendMessage({
1006
- from: "system",
1007
- to: agent.id,
1008
- kind: "control",
1009
- subject: title,
1010
- body: action === "restart"
1011
- ? "Dashboard requested that this agent restart its relay-managed session now."
1012
- : "Dashboard requested that this agent shut down its relay-managed session now.",
1013
- payload: {
1014
- agentControl: {
1015
- action,
1016
- requestedBy: "dashboard",
1017
- requestedAt: Date.now(),
1018
- },
1019
- },
1020
- meta: {
1021
- delivery: "interrupt",
1022
- priority: "urgent",
1023
- },
1105
+ const command = createCommand({
1106
+ type: action === "restart" ? "agent.restart" : "agent.shutdown",
1107
+ source: "system",
1108
+ target: agent.id,
1109
+ params: { action, agentId: agent.id, requestedBy: "dashboard", requestedAt: Date.now() },
1024
1110
  });
1025
- emitNewMessage(msg);
1111
+ emitCommand(command);
1026
1112
  auditEvent({
1027
- clientId: "server-agent-" + agent.id + "-action-" + action + "-" + msg.id,
1113
+ clientId: "server-agent-" + agent.id + "-action-" + action + "-" + command.id,
1028
1114
  kind: "state",
1029
1115
  title,
1030
1116
  body: action,
1031
1117
  meta: agent.id,
1032
1118
  icon: action === "restart" ? "ti-refresh" : "ti-power",
1033
1119
  view: "agents",
1034
- messageId: msg.id,
1035
1120
  agentId: agent.id,
1036
- metadata: { action },
1121
+ metadata: { action, commandId: command.id },
1037
1122
  });
1038
- return json({ ok: true, action, message: msg }, 202);
1123
+ return json({ ok: true, action, command }, 202);
1039
1124
  } catch (e) {
1040
1125
  if (e instanceof ValidationError) return error(e.message, 400);
1041
1126
  throw e;
@@ -1049,70 +1134,33 @@ const postAgentSpawn: Handler = async (req) => {
1049
1134
  if (!isRecord(parsed.body)) return error("provider required");
1050
1135
  const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
1051
1136
  if (!provider) return error("provider required");
1052
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
1137
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1053
1138
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1054
1139
  const label = cleanString(parsed.body.label, "label", { max: 120 });
1055
1140
 
1056
- // Check for an online orchestrator that supports this provider
1057
1141
  const orchestrators = listOrchestrators().filter(
1058
1142
  (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
1059
1143
  );
1060
- if (orchestrators.length > 0) {
1061
- // Route through the first available orchestrator
1062
- const orch = orchestrators[0]!;
1063
- const control = { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() };
1064
- const msg = sendMessage({
1065
- from: "system",
1066
- to: orch.agentId,
1067
- kind: "control",
1068
- subject: "Spawn agent",
1069
- body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1070
- payload: { orchestratorControl: control },
1071
- meta: orchestratorControlMeta(control),
1072
- });
1073
- emitNewMessage(msg);
1074
- auditEvent({
1075
- clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
1076
- kind: "state",
1077
- title: `${provider} agent spawn requested (via ${orch.id})`,
1078
- body: cwd || orch.baseDir,
1079
- meta: orch.id,
1080
- icon: "ti-plus",
1081
- view: "agents",
1082
- metadata: { provider, orchestratorId: orch.id, approvalMode },
1083
- });
1084
- return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
1085
- }
1086
-
1087
- // Fallback: direct spawn for codex only (no orchestrator)
1088
- if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
1089
- const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
1090
- const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
1091
- const result = spawnCodexAgent({
1092
- cwd,
1093
- approvalMode,
1094
- label,
1095
- relayUrl,
1096
- token: token || undefined,
1097
- dryRun: process.env.AGENT_RELAY_SPAWN_DRY_RUN === "1",
1144
+ const orch = orchestrators[0];
1145
+ if (!orch) return error("no orchestrator available for provider: " + provider);
1146
+ const command = createCommand({
1147
+ type: "agent.spawn",
1148
+ source: "system",
1149
+ target: orch.agentId,
1150
+ params: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() },
1098
1151
  });
1152
+ emitCommand(command);
1099
1153
  auditEvent({
1100
- clientId: "server-agent-spawn-codex-" + Date.now(),
1154
+ clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
1101
1155
  kind: "state",
1102
- title: "Codex agent spawn requested (direct)",
1103
- body: result.cwd,
1104
- meta: result.pid ? `pid ${result.pid}` : "dry run",
1156
+ title: `${provider} agent spawn requested (via ${orch.id})`,
1157
+ body: cwd || orch.baseDir,
1158
+ meta: orch.id,
1105
1159
  icon: "ti-plus",
1106
1160
  view: "agents",
1107
- metadata: {
1108
- provider: result.provider,
1109
- approvalMode: result.approvalMode,
1110
- pid: result.pid ?? null,
1111
- logPath: result.logPath,
1112
- dryRun: result.dryRun === true,
1113
- },
1161
+ metadata: { provider, orchestratorId: orch.id, approvalMode, commandId: command.id },
1114
1162
  });
1115
- return json(result, 202);
1163
+ return json({ ok: true, orchestratorId: orch.id, provider, command }, 202);
1116
1164
  } catch (e) {
1117
1165
  if (e instanceof ValidationError) return error(e.message, 400);
1118
1166
  if (e instanceof Error) return error(e.message, 400);
@@ -1132,11 +1180,137 @@ const getHostDirectories: Handler = (req) => {
1132
1180
  }
1133
1181
  };
1134
1182
 
1183
+ // --- Recipe routes ---
1184
+
1185
+ const getRecipes: Handler = () => {
1186
+ return json(listRecipes().map((loaded) => ({
1187
+ name: loaded.name,
1188
+ source: loaded.source,
1189
+ path: loaded.path,
1190
+ recipe: loaded.recipe,
1191
+ })));
1192
+ };
1193
+
1194
+ const getRecipeByName: Handler = (_req, params) => {
1195
+ const recipe = getRecipe(params.name!);
1196
+ return recipe ? json(recipe) : error("recipe not found", 404);
1197
+ };
1198
+
1199
+ const getRecipeInstances: Handler = (req) => {
1200
+ const url = new URL(req.url);
1201
+ const status = url.searchParams.get("status") ?? undefined;
1202
+ return json(listRecipeInstances(status as any));
1203
+ };
1204
+
1205
+ const getRecipeInstanceById: Handler = (_req, params) => {
1206
+ const instance = getRecipeInstance(params.id!);
1207
+ return instance ? json(instance) : error("recipe instance not found", 404);
1208
+ };
1209
+
1210
+ const postRecipeStart: Handler = async (req) => {
1211
+ const parsed = await parseBody<unknown>(req);
1212
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1213
+ try {
1214
+ if (!isRecord(parsed.body)) return error("recipe name required");
1215
+ const name = cleanString(parsed.body.name ?? parsed.body.recipe, "name", { required: true, max: 120 })!;
1216
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1217
+ const orchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 120 });
1218
+ const startedBy = cleanString(parsed.body.startedBy, "startedBy", { max: 120 }) ?? "api";
1219
+ const result = startRecipe({ name, cwd, orchestratorId, startedBy });
1220
+ for (const command of result.commands) emitCommand(command);
1221
+ auditEvent({
1222
+ clientId: "server-recipe-start-" + result.instance.id,
1223
+ kind: "state",
1224
+ title: "Recipe started",
1225
+ body: result.instance.recipeName,
1226
+ meta: result.instance.id,
1227
+ icon: "ti-play",
1228
+ view: "activity",
1229
+ metadata: { recipeInstanceId: result.instance.id, commands: result.commands.map((command) => command.id) },
1230
+ });
1231
+ return json(result, 202);
1232
+ } catch (e) {
1233
+ if (e instanceof ValidationError) return error(e.message, 400);
1234
+ if (e instanceof Error) return error(e.message, 400);
1235
+ throw e;
1236
+ }
1237
+ };
1238
+
1239
+ const postRecipeStop: Handler = async (req, params) => {
1240
+ const parsed = await parseBody<unknown>(req);
1241
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1242
+ try {
1243
+ const stoppedBy = isRecord(parsed.body)
1244
+ ? cleanString(parsed.body.stoppedBy, "stoppedBy", { max: 120 }) ?? "api"
1245
+ : "api";
1246
+ const result = stopRecipe(params.id!, stoppedBy);
1247
+ for (const command of result.commands) emitCommand(command);
1248
+ auditEvent({
1249
+ clientId: "server-recipe-stop-" + result.instance.id + "-" + Date.now(),
1250
+ kind: "state",
1251
+ title: "Recipe stopped",
1252
+ body: result.instance.recipeName,
1253
+ meta: result.instance.id,
1254
+ icon: "ti-player-stop",
1255
+ view: "activity",
1256
+ metadata: { recipeInstanceId: result.instance.id, commands: result.commands.map((command) => command.id) },
1257
+ });
1258
+ return json(result, 202);
1259
+ } catch (e) {
1260
+ if (e instanceof ValidationError) return error(e.message, 400);
1261
+ if (e instanceof Error) return error(e.message, e.message.includes("not found") ? 404 : 400);
1262
+ throw e;
1263
+ }
1264
+ };
1265
+
1266
+ // --- Token routes ---
1267
+
1268
+ const getTokens: Handler = () => json(listTokens());
1269
+
1270
+ const getTokenById: Handler = (_req, params) => {
1271
+ const token = getToken(params.jti!);
1272
+ return token ? json(token) : error("token not found", 404);
1273
+ };
1274
+
1275
+ const postToken: Handler = async (req) => {
1276
+ const parsed = await parseBody<unknown>(req);
1277
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1278
+ try {
1279
+ if (!isRecord(parsed.body)) return error("token body required");
1280
+ const role = cleanString(parsed.body.role, "role", { required: true, max: 80 })!;
1281
+ const sub = cleanString(parsed.body.sub, "sub", { max: 160 }) ?? role;
1282
+ const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope");
1283
+ const createdBy = cleanString(parsed.body.createdBy, "createdBy", { max: 120 });
1284
+ let ttlSeconds: number | undefined;
1285
+ if (parsed.body.ttlSeconds !== undefined) {
1286
+ if (typeof parsed.body.ttlSeconds !== "number" || !Number.isSafeInteger(parsed.body.ttlSeconds) || parsed.body.ttlSeconds <= 0) {
1287
+ throw new ValidationError("ttlSeconds must be a positive integer");
1288
+ }
1289
+ ttlSeconds = parsed.body.ttlSeconds;
1290
+ }
1291
+ return json(createToken({ sub, role, scope, ttlSeconds, createdBy }), 201);
1292
+ } catch (e) {
1293
+ if (e instanceof ValidationError) return error(e.message, 400);
1294
+ throw e;
1295
+ }
1296
+ };
1297
+
1298
+ const postTokenRevoke: Handler = (_req, params) => {
1299
+ if (!revokeToken(params.jti!)) return error("token not found or already revoked", 404);
1300
+ return json({ ok: true });
1301
+ };
1302
+
1135
1303
  // --- Orchestrator routes ---
1136
1304
 
1137
1305
  const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
1138
1306
  const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
1139
1307
 
1308
+ function cleanProtocolVersion(value: unknown): number | undefined {
1309
+ if (value === undefined) return undefined;
1310
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
1311
+ throw new ValidationError("protocolVersion must be a positive integer");
1312
+ }
1313
+
1140
1314
  const postOrchestrator: Handler = async (req) => {
1141
1315
  const parsed = await parseBody<unknown>(req);
1142
1316
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -1156,12 +1330,7 @@ const postOrchestrator: Handler = async (req) => {
1156
1330
  const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
1157
1331
  const version = cleanString(parsed.body.version, "version", { max: 80 });
1158
1332
  const gitSha = cleanString(parsed.body.gitSha, "gitSha", { max: 80 });
1159
- const protocolVersionRaw = parsed.body.protocolVersion;
1160
- const protocolVersion = protocolVersionRaw === undefined
1161
- ? undefined
1162
- : typeof protocolVersionRaw === "number" && Number.isInteger(protocolVersionRaw) && protocolVersionRaw > 0
1163
- ? protocolVersionRaw
1164
- : (() => { throw new ValidationError("protocolVersion must be a positive integer"); })();
1333
+ const protocolVersion = cleanProtocolVersion(parsed.body.protocolVersion);
1165
1334
  const meta = cleanMeta(parsed.body.meta);
1166
1335
  const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, version, protocolVersion, gitSha, meta });
1167
1336
  auditEvent({
@@ -1191,10 +1360,23 @@ const getOrchestratorById: Handler = (_req, params) => {
1191
1360
  return json(orch);
1192
1361
  };
1193
1362
 
1194
- const postOrchestratorHeartbeat: Handler = (_req, params) => {
1195
- const orch = orchestratorHeartbeat(params.id!);
1196
- if (!orch) return error("orchestrator not found", 404);
1197
- return json({ ok: true });
1363
+ const postOrchestratorHeartbeat: Handler = async (req, params) => {
1364
+ const parsed = await parseBody<unknown>(req);
1365
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1366
+ try {
1367
+ const body = isRecord(parsed.body) ? parsed.body : {};
1368
+ const runtime: OrchestratorRuntimeInput = {
1369
+ version: cleanString(body.version, "version", { max: 80 }),
1370
+ protocolVersion: cleanProtocolVersion(body.protocolVersion),
1371
+ gitSha: cleanString(body.gitSha, "gitSha", { max: 80 }),
1372
+ };
1373
+ const orch = orchestratorHeartbeat(params.id!, runtime);
1374
+ if (!orch) return error("orchestrator not found", 404);
1375
+ return json({ ok: true });
1376
+ } catch (e) {
1377
+ if (e instanceof ValidationError) return error(e.message, 400);
1378
+ throw e;
1379
+ }
1198
1380
  };
1199
1381
 
1200
1382
  const patchOrchestratorAgents: Handler = async (req, params) => {
@@ -1249,40 +1431,33 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1249
1431
  const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1250
1432
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1251
1433
 
1252
- // Send control message to orchestrator's agent inbox
1253
- const control = {
1254
- action: "spawn",
1255
- provider,
1256
- cwd: cwd || orch.baseDir,
1257
- label,
1258
- approvalMode,
1259
- prompt,
1260
- requestedBy: "dashboard",
1261
- requestedAt: Date.now(),
1262
- };
1263
- const msg = sendMessage({
1264
- from: "system",
1265
- to: orch.agentId,
1266
- kind: "control",
1267
- subject: "Spawn agent",
1268
- body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1269
- payload: {
1270
- orchestratorControl: control,
1434
+ const command = createCommand({
1435
+ type: "agent.spawn",
1436
+ source: "system",
1437
+ target: orch.agentId,
1438
+ params: {
1439
+ action: "spawn",
1440
+ provider,
1441
+ cwd: cwd || orch.baseDir,
1442
+ label,
1443
+ approvalMode,
1444
+ prompt,
1445
+ requestedBy: "dashboard",
1446
+ requestedAt: Date.now(),
1271
1447
  },
1272
- meta: orchestratorControlMeta(control),
1273
1448
  });
1274
- emitNewMessage(msg);
1449
+ emitCommand(command);
1275
1450
  auditEvent({
1276
- clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
1451
+ clientId: "server-orchestrator-spawn-" + orch.id + "-" + command.id,
1277
1452
  kind: "state",
1278
1453
  title: `Spawn ${provider} agent requested`,
1279
1454
  body: cwd || orch.baseDir,
1280
1455
  meta: orch.id,
1281
1456
  icon: "ti-plus",
1282
1457
  view: "orchestrators",
1283
- metadata: { orchestratorId: orch.id, provider, approvalMode, label },
1458
+ metadata: { orchestratorId: orch.id, provider, approvalMode, label, commandId: command.id },
1284
1459
  });
1285
- return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
1460
+ return json({ ok: true, orchestratorId: orch.id, command }, 202);
1286
1461
  } catch (e) {
1287
1462
  if (e instanceof ValidationError) return error(e.message, 400);
1288
1463
  throw e;
@@ -1301,47 +1476,231 @@ const postOrchestratorAction: Handler = async (req, params) => {
1301
1476
  if (!action) return error("action required");
1302
1477
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1303
1478
 
1304
- const control = {
1305
- action,
1306
- agentId,
1307
- requestedBy: "dashboard",
1308
- requestedAt: Date.now(),
1309
- };
1310
- const msg = sendMessage({
1311
- from: "system",
1312
- to: orch.agentId,
1313
- kind: "control",
1314
- subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1315
- body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
1316
- payload: {
1317
- orchestratorControl: control,
1318
- },
1319
- meta: orchestratorControlMeta(control),
1479
+ const command = createCommand({
1480
+ type: action === "restart" ? "agent.restart" : "agent.shutdown",
1481
+ source: "system",
1482
+ target: agentId || orch.agentId,
1483
+ params: { action, agentId, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
1320
1484
  });
1321
- emitNewMessage(msg);
1485
+ emitCommand(command);
1322
1486
  auditEvent({
1323
- clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
1487
+ clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + command.id,
1324
1488
  kind: "state",
1325
1489
  title: `Agent ${action} requested`,
1326
1490
  body: agentId || "all",
1327
1491
  meta: orch.id,
1328
1492
  icon: action === "restart" ? "ti-refresh" : "ti-power",
1329
1493
  view: "orchestrators",
1330
- metadata: { orchestratorId: orch.id, action, agentId },
1494
+ metadata: { orchestratorId: orch.id, action, agentId, commandId: command.id },
1331
1495
  });
1332
- return json({ ok: true, action, message: msg }, 202);
1496
+ return json({ ok: true, action, command }, 202);
1333
1497
  } catch (e) {
1334
1498
  if (e instanceof ValidationError) return error(e.message, 400);
1335
1499
  throw e;
1336
1500
  }
1337
1501
  };
1338
1502
 
1503
+ const getOrchestratorDirectories: Handler = async (req, params) => {
1504
+ const orch = getOrchestrator(params.id!);
1505
+ if (!orch) return error("orchestrator not found", 404);
1506
+ if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
1507
+ if (orch.status !== "online") return error("orchestrator is offline", 422);
1508
+ const url = new URL(req.url);
1509
+ const path = url.searchParams.get("path") || "";
1510
+ const proxyUrl = `${orch.apiUrl}/api/directories${path ? `?path=${encodeURIComponent(path)}` : ""}`;
1511
+ try {
1512
+ const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(5_000) });
1513
+ const body = await res.json();
1514
+ return json(body, res.status);
1515
+ } catch (e) {
1516
+ return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
1517
+ }
1518
+ };
1519
+
1339
1520
  const deleteOrchestratorById: Handler = (_req, params) => {
1340
1521
  const deleted = deleteOrchestrator(params.id!);
1341
1522
  if (!deleted) return error("orchestrator not found", 404);
1342
1523
  return json({ ok: true });
1343
1524
  };
1344
1525
 
1526
+ // --- Command routes ---
1527
+
1528
+ function normalizeCommandInput(body: unknown): CreateCommandInput {
1529
+ if (!isRecord(body)) throw new ValidationError("command body must be an object");
1530
+ const type = cleanString(body.type, "type", { required: true, max: 120 })!;
1531
+ const source = cleanString(body.source, "source", { required: true, max: 240 })!;
1532
+ const target = cleanString(body.target, "target", { required: true, max: 240 })!;
1533
+ const params = cleanParams(body.params) ?? {};
1534
+ const correlationId = cleanString(body.correlationId, "correlationId", { max: 240 });
1535
+ let ttlMs: number | undefined;
1536
+ if (body.ttlMs !== undefined) {
1537
+ if (typeof body.ttlMs !== "number" || !Number.isSafeInteger(body.ttlMs) || body.ttlMs <= 0) {
1538
+ throw new ValidationError("ttlMs must be a positive integer");
1539
+ }
1540
+ ttlMs = body.ttlMs;
1541
+ }
1542
+ return { type, source, target, params, correlationId, ttlMs };
1543
+ }
1544
+
1545
+ function commandEventType(command: Command): string {
1546
+ if (command.status === "pending") return "command.requested";
1547
+ return `command.${command.status}`;
1548
+ }
1549
+
1550
+ function emitCommand(command: Command): void {
1551
+ emitRelayEvent({
1552
+ type: commandEventType(command),
1553
+ source: command.source,
1554
+ subject: command.id,
1555
+ data: { command },
1556
+ });
1557
+ }
1558
+
1559
+ const postCommand: Handler = async (req) => {
1560
+ const parsed = await parseBody<unknown>(req);
1561
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1562
+ try {
1563
+ const command = createCommand(normalizeCommandInput(parsed.body));
1564
+ emitCommand(command);
1565
+ return json(command, 201);
1566
+ } catch (e) {
1567
+ if (e instanceof ValidationError) return error(e.message, 400);
1568
+ throw e;
1569
+ }
1570
+ };
1571
+
1572
+ const getCommands: Handler = (req) => {
1573
+ const url = new URL(req.url);
1574
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
1575
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
1576
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
1577
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
1578
+ const status = url.searchParams.get("status") ?? undefined;
1579
+ if (status && !VALID_COMMAND_STATUSES.includes(status as any)) {
1580
+ return error(`status must be one of: ${VALID_COMMAND_STATUSES.join(", ")}`);
1581
+ }
1582
+ return json(listCommands({
1583
+ target: url.searchParams.get("target") ?? undefined,
1584
+ status: status as CommandStatus | undefined,
1585
+ type: url.searchParams.get("type") ?? undefined,
1586
+ since: sinceRaw ?? undefined,
1587
+ limit: limitRaw ?? undefined,
1588
+ }));
1589
+ };
1590
+
1591
+ const getCommandById: Handler = (_req, params) => {
1592
+ const command = getCommand(params.id!);
1593
+ return command ? json(command) : error("command not found", 404);
1594
+ };
1595
+
1596
+ const patchCommand: Handler = async (req, params) => {
1597
+ const parsed = await parseBody<unknown>(req);
1598
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1599
+ if (!isRecord(parsed.body)) return error("command body must be an object");
1600
+ try {
1601
+ const status = cleanEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
1602
+ const result = cleanParams(parsed.body.result, "result");
1603
+ const err = cleanString(parsed.body.error, "error", { max: 4000 });
1604
+ const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
1605
+ if (!command) return error("command not found", 404);
1606
+ applyCommandToRecipe(command);
1607
+ emitCommand(command);
1608
+ return json(command);
1609
+ } catch (e) {
1610
+ if (e instanceof ValidationError) return error(e.message, 400);
1611
+ throw e;
1612
+ }
1613
+ };
1614
+
1615
+ // --- Provider config routes ---
1616
+
1617
+ const VALID_PROVIDER_CONFIGS = ["claude", "codex"] as const;
1618
+
1619
+ const getProviderConfigsRoute: Handler = () => {
1620
+ return json(Object.fromEntries(VALID_PROVIDER_CONFIGS.map((provider) => {
1621
+ const config = loadProviderConfig(provider);
1622
+ return [provider, providerConfigPublic(config)];
1623
+ })));
1624
+ };
1625
+
1626
+ const getProviderConfigRoute: Handler = (_req, params) => {
1627
+ const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
1628
+ if (!provider) return error("provider required");
1629
+ return json(providerConfigPublic(loadProviderConfig(provider)));
1630
+ };
1631
+
1632
+ const putProviderConfigRoute: Handler = async (req, params) => {
1633
+ const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
1634
+ if (!provider) return error("provider required");
1635
+ const parsed = await parseBody<unknown>(req);
1636
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1637
+ try {
1638
+ if (!isRecord(parsed.body)) return error("provider config body must be an object");
1639
+ const defaults = defaultProviderConfig(provider);
1640
+ const headless = isRecord(parsed.body.headless) ? parsed.body.headless : {};
1641
+ const config: ProviderConfig = {
1642
+ command: cleanString(parsed.body.command, "command", { required: true, max: 500 })!,
1643
+ defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs") ?? defaults.defaultArgs,
1644
+ env: cleanEnvRecord(parsed.body.env),
1645
+ pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs") ?? defaults.pluginDirs,
1646
+ defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities") ?? defaults.defaultCapabilities,
1647
+ defaultApprovalMode: cleanString(parsed.body.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
1648
+ defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags") ?? defaults.defaultTags,
1649
+ headless: {
1650
+ tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
1651
+ shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
1652
+ },
1653
+ };
1654
+ return json(providerConfigPublic(writeProviderConfig(provider, config)));
1655
+ } catch (e) {
1656
+ if (e instanceof ValidationError) return error(e.message, 400);
1657
+ throw e;
1658
+ }
1659
+ };
1660
+
1661
+ const postProviderConfigTestRoute: Handler = async (_req, params) => {
1662
+ const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
1663
+ if (!provider) return error("provider required");
1664
+ const config = loadProviderConfig(provider);
1665
+ const proc = Bun.spawn(["bash", "-lc", `command -v "$1"`, "bash", config.command], {
1666
+ stdout: "pipe",
1667
+ stderr: "pipe",
1668
+ });
1669
+ const [exitCode, stdout, stderr] = await Promise.all([
1670
+ proc.exited,
1671
+ new Response(proc.stdout).text(),
1672
+ new Response(proc.stderr).text(),
1673
+ ]);
1674
+ return json({ ok: exitCode === 0, command: config.command, path: stdout.trim(), error: stderr.trim() }, exitCode === 0 ? 200 : 422);
1675
+ };
1676
+
1677
+ function cleanEnvRecord(value: unknown): Record<string, string> {
1678
+ if (value === undefined) return {};
1679
+ if (!isRecord(value)) throw new ValidationError("env must be an object");
1680
+ const env: Record<string, string> = {};
1681
+ for (const [key, item] of Object.entries(value)) {
1682
+ if (typeof item !== "string") throw new ValidationError(`env.${key} must be a string`);
1683
+ env[key] = item;
1684
+ }
1685
+ return env;
1686
+ }
1687
+
1688
+ function cleanPositiveInt(value: unknown, field: string): number | undefined {
1689
+ if (value === undefined) return undefined;
1690
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
1691
+ return value;
1692
+ }
1693
+
1694
+ const deleteCommandById: Handler = (_req, params) => {
1695
+ if (!deleteCommand(params.id!)) return error("command not found or cannot be canceled", 404);
1696
+ const command = getCommand(params.id!);
1697
+ if (command) {
1698
+ applyCommandToRecipe(command);
1699
+ emitCommand(command);
1700
+ }
1701
+ return json({ ok: true });
1702
+ };
1703
+
1345
1704
  // --- Message routes ---
1346
1705
 
1347
1706
  const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
@@ -1356,6 +1715,13 @@ const postMessage: Handler = async (req) => {
1356
1715
  }
1357
1716
  applyReplyRouting(input);
1358
1717
  if (!input.to) return error("to is required (or provide replyTo to auto-route)");
1718
+ const bypassKinds = ["system", "control"];
1719
+ if (isDirectTarget(input.to) && !bypassKinds.includes(input.kind ?? "")) {
1720
+ const target = getAgent(input.to);
1721
+ if (target && target.status === "offline") {
1722
+ return error(`agent "${input.to}" is offline`, 422);
1723
+ }
1724
+ }
1359
1725
  const result = sendMessageWithResult(input);
1360
1726
  if (result.created) {
1361
1727
  emitNewMessage(result.message);
@@ -1647,7 +2013,7 @@ const postConnectorAction: Handler = async (req, params) => {
1647
2013
  try {
1648
2014
  if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
1649
2015
  const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
1650
- const result = runConnectorAction(params.id!, action);
2016
+ const result = await runConnectorAction(params.id!, action);
1651
2017
  return json(result, result.ok ? 200 : 502);
1652
2018
  } catch (e) {
1653
2019
  if (e instanceof ValidationError) return error(e.message, e.message.includes("not found") ? 404 : 400);
@@ -1719,6 +2085,83 @@ const getIntegrations: Handler = () => {
1719
2085
  return json(integrations);
1720
2086
  };
1721
2087
 
2088
+ function emitAutomationDispatch(result: AutomationDispatchResult): void {
2089
+ if (result.command) emitCommand(result.command);
2090
+ if (result.message) emitNewMessage(result.message);
2091
+ if (result.task) emitTaskChanged(result.task, "task.created");
2092
+ emitRelayEvent({
2093
+ type: "automation.run",
2094
+ source: "server",
2095
+ subject: result.run.id,
2096
+ data: { automation: result.automation, run: result.run, task: result.task },
2097
+ });
2098
+ }
2099
+
2100
+ const getAutomations: Handler = () => {
2101
+ return json(listAutomations());
2102
+ };
2103
+
2104
+ const postAutomation: Handler = async (req) => {
2105
+ const parsed = await parseBody<unknown>(req);
2106
+ if (!parsed.ok) return error(parsed.error, parsed.status);
2107
+ try {
2108
+ const automation = createAutomation(parsed.body as any);
2109
+ emitRelayEvent({ type: "automation.created", source: "server", subject: automation.id, data: { automation } });
2110
+ return json(automation, 201);
2111
+ } catch (e) {
2112
+ if (e instanceof ValidationError) return error(e.message, 400);
2113
+ throw e;
2114
+ }
2115
+ };
2116
+
2117
+ const getAutomationById: Handler = (_req, params) => {
2118
+ const automation = getAutomation(params.id!);
2119
+ return automation ? json(automation) : error("automation not found", 404);
2120
+ };
2121
+
2122
+ const patchAutomation: Handler = async (req, params) => {
2123
+ const parsed = await parseBody<unknown>(req);
2124
+ if (!parsed.ok) return error(parsed.error, parsed.status);
2125
+ try {
2126
+ const automation = updateAutomation(params.id!, parsed.body as any);
2127
+ if (!automation) return error("automation not found", 404);
2128
+ emitRelayEvent({ type: "automation.updated", source: "server", subject: automation.id, data: { automation } });
2129
+ return json(automation);
2130
+ } catch (e) {
2131
+ if (e instanceof ValidationError) return error(e.message, 400);
2132
+ throw e;
2133
+ }
2134
+ };
2135
+
2136
+ const deleteAutomationById: Handler = (_req, params) => {
2137
+ if (!deleteAutomation(params.id!)) return error("automation not found", 404);
2138
+ emitRelayEvent({ type: "automation.deleted", source: "server", subject: params.id!, data: { automationId: params.id! } });
2139
+ return json({ ok: true });
2140
+ };
2141
+
2142
+ const postAutomationRun: Handler = (_req, params) => {
2143
+ try {
2144
+ const result = runAutomationNow(params.id!);
2145
+ if (!result) return error("automation not found", 404);
2146
+ emitAutomationDispatch(result);
2147
+ return json(result, 202);
2148
+ } catch (e) {
2149
+ if (e instanceof ValidationError) return error(e.message, 400);
2150
+ throw e;
2151
+ }
2152
+ };
2153
+
2154
+ const getAutomationRuns: Handler = (req) => {
2155
+ const url = new URL(req.url);
2156
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
2157
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
2158
+ return json(listAutomationRuns({
2159
+ automationId: url.searchParams.get("automationId") ?? undefined,
2160
+ status: url.searchParams.get("status") ?? undefined,
2161
+ limit: limitRaw ?? 100,
2162
+ }));
2163
+ };
2164
+
1722
2165
  const getChannels: Handler = () => {
1723
2166
  return json(listChannels());
1724
2167
  };
@@ -1775,7 +2218,7 @@ const postChannelEvent: Handler = async (req, params) => {
1775
2218
  body: input.body,
1776
2219
  payload: input.payload,
1777
2220
  idempotencyKey: scopedChannelIdempotencyKey(input.idempotencyKey, binding),
1778
- claimable: binding.mode === "claimable",
2221
+ claimable: false,
1779
2222
  }));
1780
2223
  for (const result of results) {
1781
2224
  if (result.created) emitNewMessage(result.message);
@@ -1831,10 +2274,12 @@ const postIntegrationEvent: Handler = async (req) => {
1831
2274
  const parsed = await parseBody<unknown>(req);
1832
2275
  if (!parsed.ok) return error(parsed.error, parsed.status);
1833
2276
  try {
1834
- const input = { ...normalizeIntegrationEvent(parsed.body), source: auth.name };
1835
- if (!isIntegrationAllowed(auth, { target: input.target, channel: input.channel })) {
2277
+ const normalized = { ...normalizeIntegrationEvent(parsed.body), source: auth.name };
2278
+ const requestedChannelId = channelIdFromIntegrationTarget(normalized.target);
2279
+ if (!isIntegrationAllowed(auth, { target: normalized.target, channel: normalized.channel ?? requestedChannelId })) {
1836
2280
  return error("integration token cannot target this task", 403);
1837
2281
  }
2282
+ const input = resolveIntegrationEventTarget(normalized);
1838
2283
  const result = ingestIntegrationEvent(input, auth.name);
1839
2284
  if (result.message) emitNewMessage(result.message);
1840
2285
  emitTaskChanged(result.task, result.created ? "task.created" : "task.updated");
@@ -2124,11 +2569,30 @@ const getHealthRoute: Handler = () => json(getHealth());
2124
2569
 
2125
2570
  const postSystemReap: Handler = () => {
2126
2571
  const released = releaseExpiredClaims();
2572
+ const releasedOrphans = releaseOrphanedTasks();
2573
+ const expiredCommands = expireCommands();
2127
2574
  const reapedAgentIds = reapStaleAgents();
2128
2575
  const reapedOrchestratorIds = reapStaleOrchestrators();
2129
2576
  for (const id of released.messageIds) emitMessageClaimReleased(id);
2130
2577
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
2131
- for (const id of reapedAgentIds) emitAgentStatus(id);
2578
+ for (const task of releasedOrphans) emitTaskChanged(task, "task.updated");
2579
+ for (const command of expiredCommands) {
2580
+ applyCommandToRecipe(command);
2581
+ emitCommand(command);
2582
+ }
2583
+ for (const id of reapedAgentIds) {
2584
+ emitAgentStatus(id);
2585
+ auditEvent({
2586
+ clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
2587
+ kind: "state",
2588
+ title: "Heartbeat lost",
2589
+ body: "Agent marked offline",
2590
+ meta: id,
2591
+ icon: "ti-heart-off",
2592
+ view: "chat",
2593
+ agentId: id,
2594
+ });
2595
+ }
2132
2596
  for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
2133
2597
  const poolChanges = evaluatePoolBindings();
2134
2598
  for (const change of poolChanges) emitPoolBindingChanged(change.bindingId, change.channelId, change.previousAgentId, change.newAgentId);
@@ -2136,7 +2600,7 @@ const postSystemReap: Handler = () => {
2136
2600
  clientId: "server-system-reap-" + Date.now(),
2137
2601
  kind: "state",
2138
2602
  title: "Maintenance reaper run",
2139
- body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
2603
+ body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s), ${expiredCommands.length} command(s)`,
2140
2604
  icon: "ti-broom",
2141
2605
  view: "activity",
2142
2606
  });
@@ -2146,6 +2610,8 @@ const postSystemReap: Handler = () => {
2146
2610
  reapedOrchestratorIds,
2147
2611
  releasedMessageIds: released.messageIds,
2148
2612
  releasedTaskIds: released.tasks.map((task) => task.id),
2613
+ releasedOrphanedTaskIds: releasedOrphans.map((task) => task.id),
2614
+ expiredCommandIds: expiredCommands.map((command) => command.id),
2149
2615
  });
2150
2616
  };
2151
2617
 
@@ -2193,9 +2659,30 @@ const routes: Route[] = [
2193
2659
  route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
2194
2660
  route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
2195
2661
  route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
2662
+ route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
2196
2663
  route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
2197
2664
 
2198
2665
  route("POST", "/api/system/broadcast", postSystemBroadcast),
2666
+ route("GET", "/api/recipes", getRecipes),
2667
+ route("POST", "/api/recipes/start", postRecipeStart),
2668
+ route("GET", "/api/recipes/instances", getRecipeInstances),
2669
+ route("GET", "/api/recipes/instances/:id", getRecipeInstanceById),
2670
+ route("POST", "/api/recipes/instances/:id/stop", postRecipeStop),
2671
+ route("GET", "/api/recipes/:name", getRecipeByName),
2672
+ route("GET", "/api/tokens", getTokens),
2673
+ route("POST", "/api/tokens", postToken),
2674
+ route("GET", "/api/tokens/:jti", getTokenById),
2675
+ route("POST", "/api/tokens/:jti/revoke", postTokenRevoke),
2676
+ route("POST", "/api/commands", postCommand),
2677
+ route("GET", "/api/commands", getCommands),
2678
+ route("GET", "/api/commands/:id", getCommandById),
2679
+ route("PATCH", "/api/commands/:id", patchCommand),
2680
+ route("DELETE", "/api/commands/:id", deleteCommandById),
2681
+ route("GET", "/api/providers/config", getProviderConfigsRoute),
2682
+ route("GET", "/api/providers/:provider/config", getProviderConfigRoute),
2683
+ route("PUT", "/api/providers/:provider/config", putProviderConfigRoute),
2684
+ route("POST", "/api/providers/:provider/config/test", postProviderConfigTestRoute),
2685
+
2199
2686
  route("POST", "/api/messages", postMessage),
2200
2687
  route("GET", "/api/messages", getMessages),
2201
2688
  route("GET", "/api/messages/cursor", getCursorRoute),
@@ -2228,6 +2715,13 @@ const routes: Route[] = [
2228
2715
  route("POST", "/api/channel-bindings", postChannelBinding),
2229
2716
  route("GET", "/api/integrations", getIntegrations),
2230
2717
  route("POST", "/api/integrations/events", postIntegrationEvent),
2718
+ route("GET", "/api/automations", getAutomations),
2719
+ route("POST", "/api/automations", postAutomation),
2720
+ route("GET", "/api/automation-runs", getAutomationRuns),
2721
+ route("GET", "/api/automations/:id", getAutomationById),
2722
+ route("PATCH", "/api/automations/:id", patchAutomation),
2723
+ route("DELETE", "/api/automations/:id", deleteAutomationById),
2724
+ route("POST", "/api/automations/:id/run", postAutomationRun),
2231
2725
  route("GET", "/api/tasks", getTasks),
2232
2726
  route("GET", "/api/tasks/:id", getTaskById),
2233
2727
  route("GET", "/api/tasks/:id/events", getTaskEvents),