agent-relay-server 0.4.38 → 0.5.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.
package/src/routes.ts CHANGED
@@ -46,12 +46,20 @@ import {
46
46
  createCallbackDelivery,
47
47
  finishCallbackDelivery,
48
48
  reapStaleAgents,
49
+ reapStaleOrchestrators,
49
50
  releaseExpiredClaims,
50
51
  validateAgentSession,
52
+ upsertOrchestrator,
53
+ getOrchestrator,
54
+ listOrchestrators,
55
+ orchestratorHeartbeat,
56
+ updateManagedAgents,
57
+ deleteOrchestrator,
51
58
  ValidationError,
52
59
  } from "./db";
53
- import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
60
+ import type { ActivityEventInput, ActivityKind, AgentCard, AgentSessionGuard, ChannelDirection, ChannelSummary, CreatePairInput, IntegrationEventInput, IntegrationSummary, ManagedAgent, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, RegisterOrchestratorInput, SendMessageInput, SpawnApprovalMode, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
54
61
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
62
+ import { listHostDirectories, spawnCodexAgent, type CodexSpawnApprovalMode } from "./agent-spawn";
55
63
  import {
56
64
  getIntegrationAuth,
57
65
  hasIntegrationScope,
@@ -66,6 +74,8 @@ import {
66
74
  emitMessageClaimReleased,
67
75
  emitMessageDeleted,
68
76
  emitTaskChanged,
77
+ emitOrchestratorStatus,
78
+ emitOrchestratorRemoved,
69
79
  } from "./sse";
70
80
 
71
81
  type Handler = (
@@ -150,6 +160,9 @@ function parseQueryInt(
150
160
  }
151
161
 
152
162
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
163
+ const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
164
+ const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
165
+ const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
153
166
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
154
167
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
155
168
  const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
@@ -738,6 +751,356 @@ const deleteAgentById: Handler = (_req, params) => {
738
751
  return json({ ok: true });
739
752
  };
740
753
 
754
+ function agentCanReceiveControlAction(agent: AgentCard): boolean {
755
+ return agent.id !== "user" &&
756
+ agent.id !== "system" &&
757
+ agent.meta?.kind !== "channel" &&
758
+ !agent.tags.includes("channel");
759
+ }
760
+
761
+ const postAgentAction: Handler = async (req, params) => {
762
+ const parsed = await parseBody<unknown>(req);
763
+ if (!parsed.ok) return error(parsed.error, parsed.status);
764
+ try {
765
+ if (!isRecord(parsed.body)) return error("action required");
766
+ const action = cleanEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
767
+ if (!action) return error("action required");
768
+ const agent = getAgent(params.id!);
769
+ if (!agent) return error("agent not found", 404);
770
+ if (!agentCanReceiveControlAction(agent)) return error("agent does not support dashboard control actions", 400);
771
+
772
+ const title = action === "restart" ? "Agent restart requested" : "Agent shutdown requested";
773
+ const msg = sendMessage({
774
+ from: "system",
775
+ to: agent.id,
776
+ type: "system",
777
+ subject: title,
778
+ body: action === "restart"
779
+ ? "Dashboard requested that this agent restart its relay-managed session now."
780
+ : "Dashboard requested that this agent shut down its relay-managed session now.",
781
+ meta: {
782
+ agentControl: {
783
+ action,
784
+ requestedBy: "dashboard",
785
+ requestedAt: Date.now(),
786
+ },
787
+ delivery: "interrupt",
788
+ priority: "urgent",
789
+ },
790
+ });
791
+ emitNewMessage(msg);
792
+ auditEvent({
793
+ clientId: "server-agent-" + agent.id + "-action-" + action + "-" + msg.id,
794
+ kind: "state",
795
+ title,
796
+ body: action,
797
+ meta: agent.id,
798
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
799
+ view: "agents",
800
+ messageId: msg.id,
801
+ agentId: agent.id,
802
+ metadata: { action },
803
+ });
804
+ return json({ ok: true, action, message: msg }, 202);
805
+ } catch (e) {
806
+ if (e instanceof ValidationError) return error(e.message, 400);
807
+ throw e;
808
+ }
809
+ };
810
+
811
+ const postAgentSpawn: Handler = async (req) => {
812
+ const parsed = await parseBody<unknown>(req);
813
+ if (!parsed.ok) return error(parsed.error, parsed.status);
814
+ try {
815
+ if (!isRecord(parsed.body)) return error("provider required");
816
+ const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
817
+ if (!provider) return error("provider required");
818
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as CodexSpawnApprovalMode;
819
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
820
+ const label = cleanString(parsed.body.label, "label", { max: 120 });
821
+
822
+ // Check for an online orchestrator that supports this provider
823
+ const orchestrators = listOrchestrators().filter(
824
+ (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
825
+ );
826
+ if (orchestrators.length > 0) {
827
+ // Route through the first available orchestrator
828
+ const orch = orchestrators[0]!;
829
+ const msg = sendMessage({
830
+ from: "system",
831
+ to: orch.agentId,
832
+ type: "system",
833
+ subject: "Spawn agent",
834
+ body: JSON.stringify({ action: "spawn", provider, cwd, label, approvalMode }),
835
+ meta: {
836
+ orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() },
837
+ delivery: "interrupt",
838
+ priority: "urgent",
839
+ },
840
+ });
841
+ emitNewMessage(msg);
842
+ auditEvent({
843
+ clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
844
+ kind: "state",
845
+ title: `${provider} agent spawn requested (via ${orch.id})`,
846
+ body: cwd || orch.baseDir,
847
+ meta: orch.id,
848
+ icon: "ti-plus",
849
+ view: "agents",
850
+ metadata: { provider, orchestratorId: orch.id, approvalMode },
851
+ });
852
+ return json({ ok: true, orchestratorId: orch.id, provider, message: msg }, 202);
853
+ }
854
+
855
+ // Fallback: direct spawn for codex only (no orchestrator)
856
+ if (provider !== "codex") return error("no orchestrator available for provider: " + provider);
857
+ const relayUrl = process.env.AGENT_RELAY_SPAWN_RELAY_URL || process.env.AGENT_RELAY_URL || `http://127.0.0.1:${process.env.PORT || "4850"}`;
858
+ const token = req.headers.get("X-Agent-Relay-Token") ?? req.headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
859
+ const result = spawnCodexAgent({
860
+ cwd,
861
+ approvalMode,
862
+ label,
863
+ relayUrl,
864
+ token: token || undefined,
865
+ dryRun: process.env.AGENT_RELAY_SPAWN_DRY_RUN === "1",
866
+ });
867
+ auditEvent({
868
+ clientId: "server-agent-spawn-codex-" + Date.now(),
869
+ kind: "state",
870
+ title: "Codex agent spawn requested (direct)",
871
+ body: result.cwd,
872
+ meta: result.pid ? `pid ${result.pid}` : "dry run",
873
+ icon: "ti-plus",
874
+ view: "agents",
875
+ metadata: {
876
+ provider: result.provider,
877
+ approvalMode: result.approvalMode,
878
+ pid: result.pid ?? null,
879
+ logPath: result.logPath,
880
+ dryRun: result.dryRun === true,
881
+ },
882
+ });
883
+ return json(result, 202);
884
+ } catch (e) {
885
+ if (e instanceof ValidationError) return error(e.message, 400);
886
+ if (e instanceof Error) return error(e.message, 400);
887
+ throw e;
888
+ }
889
+ };
890
+
891
+ const getHostDirectories: Handler = (req) => {
892
+ try {
893
+ const url = new URL(req.url);
894
+ const path = cleanString(url.searchParams.get("path") ?? undefined, "path", { max: 500 });
895
+ return json(listHostDirectories(path));
896
+ } catch (e) {
897
+ if (e instanceof ValidationError) return error(e.message, 400);
898
+ if (e instanceof Error) return error(e.message, 400);
899
+ throw e;
900
+ }
901
+ };
902
+
903
+ // --- Orchestrator routes ---
904
+
905
+ const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
906
+ const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
907
+
908
+ const postOrchestrator: Handler = async (req) => {
909
+ const parsed = await parseBody<unknown>(req);
910
+ if (!parsed.ok) return error(parsed.error, parsed.status);
911
+ try {
912
+ if (!isRecord(parsed.body)) return error("body required");
913
+ const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
914
+ const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
915
+ const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
916
+ const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
917
+ if (providers) {
918
+ for (const p of providers) {
919
+ if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
920
+ return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
921
+ }
922
+ }
923
+ }
924
+ const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
925
+ const meta = cleanMeta(parsed.body.meta);
926
+ const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
927
+ auditEvent({
928
+ clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
929
+ kind: "state",
930
+ title: "Orchestrator registered",
931
+ body: hostname,
932
+ meta: id,
933
+ icon: "ti-server-2",
934
+ view: "orchestrators",
935
+ metadata: { orchestratorId: id, providers: orch.providers },
936
+ });
937
+ return json(orch, 201);
938
+ } catch (e) {
939
+ if (e instanceof ValidationError) return error(e.message, 400);
940
+ throw e;
941
+ }
942
+ };
943
+
944
+ const getOrchestrators: Handler = () => {
945
+ return json(listOrchestrators());
946
+ };
947
+
948
+ const getOrchestratorById: Handler = (_req, params) => {
949
+ const orch = getOrchestrator(params.id!);
950
+ if (!orch) return error("orchestrator not found", 404);
951
+ return json(orch);
952
+ };
953
+
954
+ const postOrchestratorHeartbeat: Handler = (_req, params) => {
955
+ const orch = orchestratorHeartbeat(params.id!);
956
+ if (!orch) return error("orchestrator not found", 404);
957
+ return json({ ok: true });
958
+ };
959
+
960
+ const patchOrchestratorAgents: Handler = async (req, params) => {
961
+ const parsed = await parseBody<unknown>(req);
962
+ if (!parsed.ok) return error(parsed.error, parsed.status);
963
+ try {
964
+ const orch = getOrchestrator(params.id!);
965
+ if (!orch) return error("orchestrator not found", 404);
966
+ if (!isRecord(parsed.body)) return error("body required");
967
+ const agents = parsed.body.agents;
968
+ if (!Array.isArray(agents)) return error("agents must be an array");
969
+ const cleaned: ManagedAgent[] = agents.map((a: any) => {
970
+ if (!isRecord(a)) throw new ValidationError("each agent must be an object");
971
+ return {
972
+ agentId: cleanString(a.agentId, "agentId", { required: true, max: 240 })!,
973
+ provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
974
+ tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!,
975
+ cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
976
+ label: cleanString(a.label, "label", { max: 120 }),
977
+ approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
978
+ pid: typeof a.pid === "number" && Number.isSafeInteger(a.pid) ? a.pid : undefined,
979
+ startedAt: typeof a.startedAt === "number" ? a.startedAt : Date.now(),
980
+ };
981
+ });
982
+ const updated = updateManagedAgents(params.id!, cleaned);
983
+ return json(updated);
984
+ } catch (e) {
985
+ if (e instanceof ValidationError) return error(e.message, 400);
986
+ throw e;
987
+ }
988
+ };
989
+
990
+ const postOrchestratorSpawn: Handler = async (req, params) => {
991
+ const parsed = await parseBody<unknown>(req);
992
+ if (!parsed.ok) return error(parsed.error, parsed.status);
993
+ try {
994
+ const orch = getOrchestrator(params.id!);
995
+ if (!orch) return error("orchestrator not found", 404);
996
+ if (orch.status !== "online") return error("orchestrator is offline", 409);
997
+
998
+ if (!isRecord(parsed.body)) return error("body required");
999
+ const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
1000
+ if (!orch.providers.includes(provider)) {
1001
+ return error(`orchestrator does not support provider: ${provider}`);
1002
+ }
1003
+ const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1004
+ if (cwd && !cwd.startsWith(orch.baseDir)) {
1005
+ return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
1006
+ }
1007
+ const label = cleanString(parsed.body.label, "label", { max: 120 });
1008
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1009
+ const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1010
+
1011
+ // Send control message to orchestrator's agent inbox
1012
+ const msg = sendMessage({
1013
+ from: "system",
1014
+ to: orch.agentId,
1015
+ type: "system",
1016
+ subject: "Spawn agent",
1017
+ body: JSON.stringify({ action: "spawn", provider, cwd: cwd || orch.baseDir, label, approvalMode, prompt }),
1018
+ meta: {
1019
+ orchestratorControl: {
1020
+ action: "spawn",
1021
+ provider,
1022
+ cwd: cwd || orch.baseDir,
1023
+ label,
1024
+ approvalMode,
1025
+ prompt,
1026
+ requestedBy: "dashboard",
1027
+ requestedAt: Date.now(),
1028
+ },
1029
+ delivery: "interrupt",
1030
+ priority: "urgent",
1031
+ },
1032
+ });
1033
+ emitNewMessage(msg);
1034
+ auditEvent({
1035
+ clientId: "server-orchestrator-spawn-" + orch.id + "-" + Date.now(),
1036
+ kind: "state",
1037
+ title: `Spawn ${provider} agent requested`,
1038
+ body: cwd || orch.baseDir,
1039
+ meta: orch.id,
1040
+ icon: "ti-plus",
1041
+ view: "orchestrators",
1042
+ metadata: { orchestratorId: orch.id, provider, approvalMode, label },
1043
+ });
1044
+ return json({ ok: true, orchestratorId: orch.id, message: msg }, 202);
1045
+ } catch (e) {
1046
+ if (e instanceof ValidationError) return error(e.message, 400);
1047
+ throw e;
1048
+ }
1049
+ };
1050
+
1051
+ const postOrchestratorAction: Handler = async (req, params) => {
1052
+ const parsed = await parseBody<unknown>(req);
1053
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1054
+ try {
1055
+ const orch = getOrchestrator(params.id!);
1056
+ if (!orch) return error("orchestrator not found", 404);
1057
+
1058
+ if (!isRecord(parsed.body)) return error("body required");
1059
+ const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
1060
+ if (!action) return error("action required");
1061
+ const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1062
+
1063
+ const msg = sendMessage({
1064
+ from: "system",
1065
+ to: orch.agentId,
1066
+ type: "system",
1067
+ subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1068
+ body: JSON.stringify({ action, agentId }),
1069
+ meta: {
1070
+ orchestratorControl: {
1071
+ action,
1072
+ agentId,
1073
+ requestedBy: "dashboard",
1074
+ requestedAt: Date.now(),
1075
+ },
1076
+ delivery: "interrupt",
1077
+ priority: "urgent",
1078
+ },
1079
+ });
1080
+ emitNewMessage(msg);
1081
+ auditEvent({
1082
+ clientId: "server-orchestrator-action-" + orch.id + "-" + action + "-" + Date.now(),
1083
+ kind: "state",
1084
+ title: `Agent ${action} requested`,
1085
+ body: agentId || "all",
1086
+ meta: orch.id,
1087
+ icon: action === "restart" ? "ti-refresh" : "ti-power",
1088
+ view: "orchestrators",
1089
+ metadata: { orchestratorId: orch.id, action, agentId },
1090
+ });
1091
+ return json({ ok: true, action, message: msg }, 202);
1092
+ } catch (e) {
1093
+ if (e instanceof ValidationError) return error(e.message, 400);
1094
+ throw e;
1095
+ }
1096
+ };
1097
+
1098
+ const deleteOrchestratorById: Handler = (_req, params) => {
1099
+ const deleted = deleteOrchestrator(params.id!);
1100
+ if (!deleted) return error("orchestrator not found", 404);
1101
+ return json({ ok: true });
1102
+ };
1103
+
741
1104
  // --- Message routes ---
742
1105
 
743
1106
  const VALID_MSG_TYPES = ["message", "system"];
@@ -1369,20 +1732,23 @@ const getHealthRoute: Handler = () => json(getHealth());
1369
1732
  const postSystemReap: Handler = () => {
1370
1733
  const released = releaseExpiredClaims();
1371
1734
  const reapedAgentIds = reapStaleAgents();
1735
+ const reapedOrchestratorIds = reapStaleOrchestrators();
1372
1736
  for (const id of released.messageIds) emitMessageClaimReleased(id);
1373
1737
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
1374
1738
  for (const id of reapedAgentIds) emitAgentStatus(id);
1739
+ for (const id of reapedOrchestratorIds) emitOrchestratorStatus(id);
1375
1740
  auditEvent({
1376
1741
  clientId: "server-system-reap-" + Date.now(),
1377
1742
  kind: "state",
1378
1743
  title: "Maintenance reaper run",
1379
- body: `${reapedAgentIds.length} stale agent(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
1744
+ body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s)`,
1380
1745
  icon: "ti-broom",
1381
1746
  view: "activity",
1382
1747
  });
1383
1748
  return json({
1384
1749
  ok: true,
1385
1750
  reapedAgentIds,
1751
+ reapedOrchestratorIds,
1386
1752
  releasedMessageIds: released.messageIds,
1387
1753
  releasedTaskIds: released.tasks.map((task) => task.id),
1388
1754
  });
@@ -1412,16 +1778,28 @@ function route(method: string, path: string, handler: Handler): Route {
1412
1778
 
1413
1779
  const routes: Route[] = [
1414
1780
  route("POST", "/api/agents", postAgent),
1781
+ route("POST", "/api/agents/spawn", postAgentSpawn),
1415
1782
  route("GET", "/api/agents", getAgents),
1416
1783
  route("GET", "/api/agents/find", findAgents),
1784
+ route("GET", "/api/agents/spawn/directories", getHostDirectories),
1417
1785
  route("GET", "/api/agents/:id", getAgentById),
1418
1786
  route("PATCH", "/api/agents/:id/status", patchAgentStatus),
1419
1787
  route("PATCH", "/api/agents/:id/ready", patchAgentReady),
1420
1788
  route("PATCH", "/api/agents/:id/label", patchAgentLabel),
1421
1789
  route("PATCH", "/api/agents/:id/tags", patchAgentTags),
1422
1790
  route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
1791
+ route("POST", "/api/agents/:id/actions", postAgentAction),
1423
1792
  route("DELETE", "/api/agents/:id", deleteAgentById),
1424
1793
 
1794
+ route("POST", "/api/orchestrators", postOrchestrator),
1795
+ route("GET", "/api/orchestrators", getOrchestrators),
1796
+ route("GET", "/api/orchestrators/:id", getOrchestratorById),
1797
+ route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
1798
+ route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
1799
+ route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
1800
+ route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
1801
+ route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
1802
+
1425
1803
  route("POST", "/api/system/broadcast", postSystemBroadcast),
1426
1804
  route("POST", "/api/messages", postMessage),
1427
1805
  route("GET", "/api/messages", getMessages),
package/src/sse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getAgent } from "./db";
1
+ import { getAgent, getOrchestrator } from "./db";
2
2
  import type { Message, Task } from "./types";
3
3
 
4
4
  interface Connection {
@@ -135,3 +135,17 @@ function targetMatchesAgent(target: string, agentId: string): boolean {
135
135
  if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
136
136
  return false;
137
137
  }
138
+
139
+ export function emitOrchestratorStatus(orchestratorId: string) {
140
+ const orch = getOrchestrator(orchestratorId);
141
+ const data = orch ?? { id: orchestratorId, status: "offline" };
142
+ for (const conn of connections.values()) {
143
+ send(conn, "orchestrator.status", data);
144
+ }
145
+ }
146
+
147
+ export function emitOrchestratorRemoved(orchestratorId: string) {
148
+ for (const conn of connections.values()) {
149
+ send(conn, "orchestrator.removed", { id: orchestratorId });
150
+ }
151
+ }
package/src/types.ts CHANGED
@@ -291,6 +291,66 @@ export interface ActivityEventInput {
291
291
  metadata?: Record<string, unknown>;
292
292
  }
293
293
 
294
+ // --- Orchestrators ---
295
+
296
+ export type OrchestratorStatus = "online" | "offline";
297
+ export type SpawnProvider = "claude" | "codex";
298
+ export type SpawnApprovalMode = "open" | "guarded" | "read-only";
299
+
300
+ export interface Orchestrator {
301
+ id: string;
302
+ hostname: string;
303
+ status: OrchestratorStatus;
304
+ agentId: string; // relay agent id for messaging
305
+ providers: SpawnProvider[];
306
+ baseDir: string;
307
+ envKeys: string[]; // names only, never values
308
+ meta: Record<string, unknown>;
309
+ managedAgents: ManagedAgent[];
310
+ lastSeen: number;
311
+ createdAt: number;
312
+ }
313
+
314
+ export interface ManagedAgent {
315
+ agentId: string;
316
+ provider: SpawnProvider;
317
+ tmuxSession: string;
318
+ cwd: string;
319
+ label?: string;
320
+ approvalMode: SpawnApprovalMode;
321
+ pid?: number;
322
+ startedAt: number;
323
+ }
324
+
325
+ export interface RegisterOrchestratorInput {
326
+ id: string;
327
+ hostname: string;
328
+ providers: SpawnProvider[];
329
+ baseDir: string;
330
+ envKeys?: string[];
331
+ meta?: Record<string, unknown>;
332
+ }
333
+
334
+ export interface OrchestratorSpawnInput {
335
+ provider: SpawnProvider;
336
+ cwd?: string;
337
+ label?: string;
338
+ approvalMode?: SpawnApprovalMode;
339
+ prompt?: string;
340
+ env?: Record<string, string>;
341
+ }
342
+
343
+ export interface OrchestratorSpawnResult {
344
+ orchestratorId: string;
345
+ provider: SpawnProvider;
346
+ tmuxSession: string;
347
+ cwd: string;
348
+ label?: string;
349
+ approvalMode: SpawnApprovalMode;
350
+ pid?: number;
351
+ startedAt: number;
352
+ }
353
+
294
354
  export interface HealthCheck {
295
355
  name: string;
296
356
  status: "ok" | "warn" | "error";