agent-relay-server 0.8.1 → 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 +2 -1
  21. package/src/connectors.ts +29 -9
  22. package/src/daemon.ts +1 -0
  23. package/src/db.ts +363 -36
  24. package/src/events.ts +33 -0
  25. package/src/index.ts +100 -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 +661 -158
  31. package/src/security.ts +128 -2
  32. package/src/sse.ts +45 -28
  33. package/src/token-db.ts +96 -0
  34. package/src/types.ts +1 -488
  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,
@@ -60,8 +61,13 @@ import {
60
61
  orchestratorHeartbeat,
61
62
  updateManagedAgents,
62
63
  deleteOrchestrator,
64
+ evaluatePoolBindings,
63
65
  ValidationError,
64
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";
65
71
  import {
66
72
  getConnector,
67
73
  listConnectors,
@@ -70,9 +76,21 @@ import {
70
76
  runConnectorAction,
71
77
  writeConnectorConfig,
72
78
  } from "./connectors";
73
- 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";
74
90
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
75
- 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";
76
94
  import {
77
95
  getIntegrationAuth,
78
96
  hasIntegrationScope,
@@ -90,7 +108,9 @@ import {
90
108
  emitChannelActivity,
91
109
  emitOrchestratorStatus,
92
110
  emitOrchestratorRemoved,
111
+ emitPoolBindingChanged,
93
112
  } from "./sse";
113
+ import { emitRelayEvent } from "./events";
94
114
 
95
115
  type Handler = (
96
116
  req: Request,
@@ -173,16 +193,17 @@ function parseQueryInt(
173
193
  return n;
174
194
  }
175
195
 
176
- const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
196
+ const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
177
197
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
178
- const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator"] as const;
179
- const VALID_CHANNEL_BINDING_MODES = ["exclusive", "claimable", "broadcast"] as const;
198
+ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool"] as const;
199
+ const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
180
200
  const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
181
201
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
182
202
  const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
183
203
  const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
184
204
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
185
- 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;
186
207
  const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
187
208
  const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state"] as const;
188
209
  const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
@@ -230,6 +251,13 @@ function cleanMeta(value: unknown): Record<string, unknown> | undefined {
230
251
  return value;
231
252
  }
232
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
+
233
261
  function cleanEnum<T extends readonly string[]>(
234
262
  value: unknown,
235
263
  field: string,
@@ -355,6 +383,11 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
355
383
  return input;
356
384
  }
357
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
+
358
391
  function applyReplyRouting(input: SendMessageInput): void {
359
392
  if (input.to || !input.replyTo) return;
360
393
  const parent = getMessage(input.replyTo);
@@ -404,6 +437,7 @@ function normalizeChannelBindingInput(body: unknown): {
404
437
  }
405
438
 
406
439
  function routeTargetFromAddress(target: string): ChannelRouteTarget {
440
+ if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
407
441
  if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
408
442
  if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
409
443
  if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
@@ -412,8 +446,12 @@ function routeTargetFromAddress(target: string): ChannelRouteTarget {
412
446
  return { type: "agent", id: target };
413
447
  }
414
448
 
415
- function messageTargetForChannelTarget(target: ChannelRouteTarget): string {
449
+ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: ChannelBinding): string {
416
450
  if (target.type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
451
+ if (target.type === "pool") {
452
+ if (!binding?.poolAgentId) throw new ValidationError("pool slot is unclaimed — no eligible agent available");
453
+ return binding.poolAgentId;
454
+ }
417
455
  if (target.type === "label") return `label:${target.id}`;
418
456
  if (target.type === "tag") return `tag:${target.id}`;
419
457
  if (target.type === "capability") return `cap:${target.id}`;
@@ -550,6 +588,53 @@ function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
550
588
  };
551
589
  }
552
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
+
553
638
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
554
639
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
555
640
  const status = cleanEnum(body.status, "status", VALID_TASK_STATUSES);
@@ -754,10 +839,11 @@ function agentToChannel(agent: AgentCard): ChannelSummary {
754
839
  async function dispatchTaskCallbacks(taskId: number, eventType: string): Promise<void> {
755
840
  const task = getTask(taskId);
756
841
  if (!task) return;
842
+ const requestedTarget = typeof task.metadata?.relayRequestedTarget === "string" ? task.metadata.relayRequestedTarget : undefined;
757
843
  const integrations = getIntegrationTokens()
758
844
  .filter((integration) => integration.name === task.source)
759
845
  .filter((integration) => integration.callbackUrl)
760
- .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)))
761
847
  .filter((integration) => !integration.channels?.length || !task.channel || integration.channels.includes(task.channel));
762
848
 
763
849
  for (const integration of integrations) {
@@ -825,15 +911,6 @@ function auditAgentStateTransition(agentId: string, before: AgentCard | null | u
825
911
  });
826
912
  }
827
913
 
828
- function orchestratorControlMeta(control: Record<string, unknown>): Record<string, unknown> {
829
- return {
830
- delivery: "interrupt",
831
- priority: "urgent",
832
- // Legacy orchestrators read control details from message meta.
833
- orchestratorControl: control,
834
- };
835
- }
836
-
837
914
  // --- Agent routes ---
838
915
 
839
916
  const postAgent: Handler = async (req) => {
@@ -889,14 +966,14 @@ const patchAgentStatus: Handler = async (req, params) => {
889
966
  if (!parsed.ok) return error(parsed.error, parsed.status);
890
967
  const body = parsed.body;
891
968
  if (!body?.status) return error("status required");
892
- const valid = ["online", "idle", "busy", "offline"];
893
- 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(", ")}`);
894
971
  try {
895
972
  const guard = normalizeAgentSessionGuard(req, body);
896
973
  const session = validateAgentSession(params.id!, guard);
897
974
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
898
975
  const before = getAgent(params.id!);
899
- 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);
900
977
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
901
978
  } catch (e) {
902
979
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -925,8 +1002,23 @@ const patchAgentLabel: Handler = async (req, params) => {
925
1002
  if (!parsed.ok) return error(parsed.error, parsed.status);
926
1003
  const body = parsed.body;
927
1004
  if (body === null || !("label" in body)) return error("label field required (string or null)");
1005
+ const before = getAgent(params.id!);
928
1006
  if (!setLabel(params.id!, body.label)) return error("agent not found", 404);
929
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
+ }
930
1022
  return json({ ok: true });
931
1023
  };
932
1024
 
@@ -936,9 +1028,23 @@ const patchAgentTags: Handler = async (req, params) => {
936
1028
  try {
937
1029
  const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
938
1030
  if (!tags) return error("tags field required");
1031
+ const before = getAgent(params.id!);
939
1032
  const agent = setTags(params.id!, tags);
940
1033
  if (!agent) return error("agent not found", 404);
941
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
+ }
942
1048
  return json(agent);
943
1049
  } catch (e) {
944
1050
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -980,7 +1086,8 @@ function agentCanReceiveControlAction(agent: AgentCard): boolean {
980
1086
  return agent.id !== "user" &&
981
1087
  agent.id !== "system" &&
982
1088
  agent.meta?.kind !== "channel" &&
983
- !agent.tags.includes("channel");
1089
+ !agent.tags.includes("channel") &&
1090
+ agent.meta?.runnerManaged === true;
984
1091
  }
985
1092
 
986
1093
  const postAgentAction: Handler = async (req, params) => {
@@ -995,40 +1102,25 @@ const postAgentAction: Handler = async (req, params) => {
995
1102
  if (!agentCanReceiveControlAction(agent)) return error("agent does not support dashboard control actions", 400);
996
1103
 
997
1104
  const title = action === "restart" ? "Agent restart requested" : "Agent shutdown requested";
998
- const msg = sendMessage({
999
- from: "system",
1000
- to: agent.id,
1001
- kind: "control",
1002
- subject: title,
1003
- body: action === "restart"
1004
- ? "Dashboard requested that this agent restart its relay-managed session now."
1005
- : "Dashboard requested that this agent shut down its relay-managed session now.",
1006
- payload: {
1007
- agentControl: {
1008
- action,
1009
- requestedBy: "dashboard",
1010
- requestedAt: Date.now(),
1011
- },
1012
- },
1013
- meta: {
1014
- delivery: "interrupt",
1015
- priority: "urgent",
1016
- },
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() },
1017
1110
  });
1018
- emitNewMessage(msg);
1111
+ emitCommand(command);
1019
1112
  auditEvent({
1020
- clientId: "server-agent-" + agent.id + "-action-" + action + "-" + msg.id,
1113
+ clientId: "server-agent-" + agent.id + "-action-" + action + "-" + command.id,
1021
1114
  kind: "state",
1022
1115
  title,
1023
1116
  body: action,
1024
1117
  meta: agent.id,
1025
1118
  icon: action === "restart" ? "ti-refresh" : "ti-power",
1026
1119
  view: "agents",
1027
- messageId: msg.id,
1028
1120
  agentId: agent.id,
1029
- metadata: { action },
1121
+ metadata: { action, commandId: command.id },
1030
1122
  });
1031
- return json({ ok: true, action, message: msg }, 202);
1123
+ return json({ ok: true, action, command }, 202);
1032
1124
  } catch (e) {
1033
1125
  if (e instanceof ValidationError) return error(e.message, 400);
1034
1126
  throw e;
@@ -1042,70 +1134,33 @@ const postAgentSpawn: Handler = async (req) => {
1042
1134
  if (!isRecord(parsed.body)) return error("provider required");
1043
1135
  const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
1044
1136
  if (!provider) return error("provider required");
1045
- 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;
1046
1138
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1047
1139
  const label = cleanString(parsed.body.label, "label", { max: 120 });
1048
1140
 
1049
- // Check for an online orchestrator that supports this provider
1050
1141
  const orchestrators = listOrchestrators().filter(
1051
1142
  (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
1052
1143
  );
1053
- if (orchestrators.length > 0) {
1054
- // Route through the first available orchestrator
1055
- const orch = orchestrators[0]!;
1056
- const control = { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() };
1057
- const msg = sendMessage({
1058
- from: "system",
1059
- to: orch.agentId,
1060
- kind: "control",
1061
- subject: "Spawn agent",
1062
- body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1063
- payload: { orchestratorControl: control },
1064
- meta: orchestratorControlMeta(control),
1065
- });
1066
- emitNewMessage(msg);
1067
- auditEvent({
1068
- clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
1069
- kind: "state",
1070
- title: `${provider} agent spawn requested (via ${orch.id})`,
1071
- body: cwd || orch.baseDir,
1072
- meta: orch.id,
1073
- icon: "ti-plus",
1074
- view: "agents",
1075
- metadata: { provider, orchestratorId: orch.id, approvalMode },
1076
- });
1077
- return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
1078
- }
1079
-
1080
- // Fallback: direct spawn for codex only (no orchestrator)
1081
- if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
1082
- const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
1083
- const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
1084
- const result = spawnCodexAgent({
1085
- cwd,
1086
- approvalMode,
1087
- label,
1088
- relayUrl,
1089
- token: token || undefined,
1090
- 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() },
1091
1151
  });
1152
+ emitCommand(command);
1092
1153
  auditEvent({
1093
- clientId: "server-agent-spawn-codex-" + Date.now(),
1154
+ clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
1094
1155
  kind: "state",
1095
- title: "Codex agent spawn requested (direct)",
1096
- body: result.cwd,
1097
- 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,
1098
1159
  icon: "ti-plus",
1099
1160
  view: "agents",
1100
- metadata: {
1101
- provider: result.provider,
1102
- approvalMode: result.approvalMode,
1103
- pid: result.pid ?? null,
1104
- logPath: result.logPath,
1105
- dryRun: result.dryRun === true,
1106
- },
1161
+ metadata: { provider, orchestratorId: orch.id, approvalMode, commandId: command.id },
1107
1162
  });
1108
- return json(result, 202);
1163
+ return json({ ok: true, orchestratorId: orch.id, provider, command }, 202);
1109
1164
  } catch (e) {
1110
1165
  if (e instanceof ValidationError) return error(e.message, 400);
1111
1166
  if (e instanceof Error) return error(e.message, 400);
@@ -1125,11 +1180,137 @@ const getHostDirectories: Handler = (req) => {
1125
1180
  }
1126
1181
  };
1127
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
+
1128
1303
  // --- Orchestrator routes ---
1129
1304
 
1130
1305
  const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
1131
1306
  const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
1132
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
+
1133
1314
  const postOrchestrator: Handler = async (req) => {
1134
1315
  const parsed = await parseBody<unknown>(req);
1135
1316
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -1149,12 +1330,7 @@ const postOrchestrator: Handler = async (req) => {
1149
1330
  const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
1150
1331
  const version = cleanString(parsed.body.version, "version", { max: 80 });
1151
1332
  const gitSha = cleanString(parsed.body.gitSha, "gitSha", { max: 80 });
1152
- const protocolVersionRaw = parsed.body.protocolVersion;
1153
- const protocolVersion = protocolVersionRaw === undefined
1154
- ? undefined
1155
- : typeof protocolVersionRaw === "number" && Number.isInteger(protocolVersionRaw) && protocolVersionRaw > 0
1156
- ? protocolVersionRaw
1157
- : (() => { throw new ValidationError("protocolVersion must be a positive integer"); })();
1333
+ const protocolVersion = cleanProtocolVersion(parsed.body.protocolVersion);
1158
1334
  const meta = cleanMeta(parsed.body.meta);
1159
1335
  const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, version, protocolVersion, gitSha, meta });
1160
1336
  auditEvent({
@@ -1184,10 +1360,23 @@ const getOrchestratorById: Handler = (_req, params) => {
1184
1360
  return json(orch);
1185
1361
  };
1186
1362
 
1187
- const postOrchestratorHeartbeat: Handler = (_req, params) => {
1188
- const orch = orchestratorHeartbeat(params.id!);
1189
- if (!orch) return error("orchestrator not found", 404);
1190
- 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
+ }
1191
1380
  };
1192
1381
 
1193
1382
  const patchOrchestratorAgents: Handler = async (req, params) => {
@@ -1242,40 +1431,33 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1242
1431
  const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1243
1432
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1244
1433
 
1245
- // Send control message to orchestrator's agent inbox
1246
- const control = {
1247
- action: "spawn",
1248
- provider,
1249
- cwd: cwd || orch.baseDir,
1250
- label,
1251
- approvalMode,
1252
- prompt,
1253
- requestedBy: "dashboard",
1254
- requestedAt: Date.now(),
1255
- };
1256
- const msg = sendMessage({
1257
- from: "system",
1258
- to: orch.agentId,
1259
- kind: "control",
1260
- subject: "Spawn agent",
1261
- body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1262
- payload: {
1263
- 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(),
1264
1447
  },
1265
- meta: orchestratorControlMeta(control),
1266
1448
  });
1267
- emitNewMessage(msg);
1449
+ emitCommand(command);
1268
1450
  auditEvent({
1269
- clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
1451
+ clientId: "server-orchestrator-spawn-" + orch.id + "-" + command.id,
1270
1452
  kind: "state",
1271
1453
  title: `Spawn ${provider} agent requested`,
1272
1454
  body: cwd || orch.baseDir,
1273
1455
  meta: orch.id,
1274
1456
  icon: "ti-plus",
1275
1457
  view: "orchestrators",
1276
- metadata: { orchestratorId: orch.id, provider, approvalMode, label },
1458
+ metadata: { orchestratorId: orch.id, provider, approvalMode, label, commandId: command.id },
1277
1459
  });
1278
- return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
1460
+ return json({ ok: true, orchestratorId: orch.id, command }, 202);
1279
1461
  } catch (e) {
1280
1462
  if (e instanceof ValidationError) return error(e.message, 400);
1281
1463
  throw e;
@@ -1294,47 +1476,231 @@ const postOrchestratorAction: Handler = async (req, params) => {
1294
1476
  if (!action) return error("action required");
1295
1477
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1296
1478
 
1297
- const control = {
1298
- action,
1299
- agentId,
1300
- requestedBy: "dashboard",
1301
- requestedAt: Date.now(),
1302
- };
1303
- const msg = sendMessage({
1304
- from: "system",
1305
- to: orch.agentId,
1306
- kind: "control",
1307
- subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1308
- body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
1309
- payload: {
1310
- orchestratorControl: control,
1311
- },
1312
- 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 },
1313
1484
  });
1314
- emitNewMessage(msg);
1485
+ emitCommand(command);
1315
1486
  auditEvent({
1316
- clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
1487
+ clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + command.id,
1317
1488
  kind: "state",
1318
1489
  title: `Agent ${action} requested`,
1319
1490
  body: agentId || "all",
1320
1491
  meta: orch.id,
1321
1492
  icon: action === "restart" ? "ti-refresh" : "ti-power",
1322
1493
  view: "orchestrators",
1323
- metadata: { orchestratorId: orch.id, action, agentId },
1494
+ metadata: { orchestratorId: orch.id, action, agentId, commandId: command.id },
1324
1495
  });
1325
- return json({ ok: true, action, message: msg }, 202);
1496
+ return json({ ok: true, action, command }, 202);
1326
1497
  } catch (e) {
1327
1498
  if (e instanceof ValidationError) return error(e.message, 400);
1328
1499
  throw e;
1329
1500
  }
1330
1501
  };
1331
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
+
1332
1520
  const deleteOrchestratorById: Handler = (_req, params) => {
1333
1521
  const deleted = deleteOrchestrator(params.id!);
1334
1522
  if (!deleted) return error("orchestrator not found", 404);
1335
1523
  return json({ ok: true });
1336
1524
  };
1337
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
+
1338
1704
  // --- Message routes ---
1339
1705
 
1340
1706
  const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
@@ -1349,6 +1715,13 @@ const postMessage: Handler = async (req) => {
1349
1715
  }
1350
1716
  applyReplyRouting(input);
1351
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
+ }
1352
1725
  const result = sendMessageWithResult(input);
1353
1726
  if (result.created) {
1354
1727
  emitNewMessage(result.message);
@@ -1640,7 +2013,7 @@ const postConnectorAction: Handler = async (req, params) => {
1640
2013
  try {
1641
2014
  if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
1642
2015
  const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
1643
- const result = runConnectorAction(params.id!, action);
2016
+ const result = await runConnectorAction(params.id!, action);
1644
2017
  return json(result, result.ok ? 200 : 502);
1645
2018
  } catch (e) {
1646
2019
  if (e instanceof ValidationError) return error(e.message, e.message.includes("not found") ? 404 : 400);
@@ -1712,6 +2085,83 @@ const getIntegrations: Handler = () => {
1712
2085
  return json(integrations);
1713
2086
  };
1714
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
+
1715
2165
  const getChannels: Handler = () => {
1716
2166
  return json(listChannels());
1717
2167
  };
@@ -1762,13 +2212,13 @@ const postChannelEvent: Handler = async (req, params) => {
1762
2212
  if (!bindings.length) return error("channel has no binding", 409);
1763
2213
  const results = bindings.map((binding) => sendMessageWithResult({
1764
2214
  from: channel.agentId,
1765
- to: messageTargetForChannelTarget(binding.target),
2215
+ to: messageTargetForChannelTarget(binding.target, binding),
1766
2216
  kind: "channel.event",
1767
2217
  channel: channel.id,
1768
2218
  body: input.body,
1769
2219
  payload: input.payload,
1770
2220
  idempotencyKey: scopedChannelIdempotencyKey(input.idempotencyKey, binding),
1771
- claimable: binding.mode === "claimable",
2221
+ claimable: false,
1772
2222
  }));
1773
2223
  for (const result of results) {
1774
2224
  if (result.created) emitNewMessage(result.message);
@@ -1824,10 +2274,12 @@ const postIntegrationEvent: Handler = async (req) => {
1824
2274
  const parsed = await parseBody<unknown>(req);
1825
2275
  if (!parsed.ok) return error(parsed.error, parsed.status);
1826
2276
  try {
1827
- const input = { ...normalizeIntegrationEvent(parsed.body), source: auth.name };
1828
- 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 })) {
1829
2280
  return error("integration token cannot target this task", 403);
1830
2281
  }
2282
+ const input = resolveIntegrationEventTarget(normalized);
1831
2283
  const result = ingestIntegrationEvent(input, auth.name);
1832
2284
  if (result.message) emitNewMessage(result.message);
1833
2285
  emitTaskChanged(result.task, result.created ? "task.created" : "task.updated");
@@ -2117,17 +2569,38 @@ const getHealthRoute: Handler = () => json(getHealth());
2117
2569
 
2118
2570
  const postSystemReap: Handler = () => {
2119
2571
  const released = releaseExpiredClaims();
2572
+ const releasedOrphans = releaseOrphanedTasks();
2573
+ const expiredCommands = expireCommands();
2120
2574
  const reapedAgentIds = reapStaleAgents();
2121
2575
  const reapedOrchestratorIds = reapStaleOrchestrators();
2122
2576
  for (const id of released.messageIds) emitMessageClaimReleased(id);
2123
2577
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
2124
- 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
+ }
2125
2596
  for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
2597
+ const poolChanges = evaluatePoolBindings();
2598
+ for (const change of poolChanges) emitPoolBindingChanged(change.bindingId, change.channelId, change.previousAgentId, change.newAgentId);
2126
2599
  auditEvent({
2127
2600
  clientId: "server-system-reap-" + Date.now(),
2128
2601
  kind: "state",
2129
2602
  title: "Maintenance reaper run",
2130
- 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)`,
2131
2604
  icon: "ti-broom",
2132
2605
  view: "activity",
2133
2606
  });
@@ -2137,6 +2610,8 @@ const postSystemReap: Handler = () => {
2137
2610
  reapedOrchestratorIds,
2138
2611
  releasedMessageIds: released.messageIds,
2139
2612
  releasedTaskIds: released.tasks.map((task) => task.id),
2613
+ releasedOrphanedTaskIds: releasedOrphans.map((task) => task.id),
2614
+ expiredCommandIds: expiredCommands.map((command) => command.id),
2140
2615
  });
2141
2616
  };
2142
2617
 
@@ -2184,9 +2659,30 @@ const routes: Route[] = [
2184
2659
  route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
2185
2660
  route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
2186
2661
  route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
2662
+ route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
2187
2663
  route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
2188
2664
 
2189
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
+
2190
2686
  route("POST", "/api/messages", postMessage),
2191
2687
  route("GET", "/api/messages", getMessages),
2192
2688
  route("GET", "/api/messages/cursor", getCursorRoute),
@@ -2219,6 +2715,13 @@ const routes: Route[] = [
2219
2715
  route("POST", "/api/channel-bindings", postChannelBinding),
2220
2716
  route("GET", "/api/integrations", getIntegrations),
2221
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),
2222
2725
  route("GET", "/api/tasks", getTasks),
2223
2726
  route("GET", "/api/tasks/:id", getTaskById),
2224
2727
  route("GET", "/api/tasks/:id/events", getTaskEvents),