agent-relay-server 0.10.7 → 0.10.8

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
@@ -11,9 +11,11 @@ import {
11
11
  deleteAgent,
12
12
  sendMessage,
13
13
  sendMessageWithResult,
14
+ getMessageDeliveryStatus,
14
15
  getMessage,
15
16
  getThread,
16
17
  claimMessage,
18
+ listQueuedMessages,
17
19
  listRecentMessages,
18
20
  pollMessages,
19
21
  markRead,
@@ -53,6 +55,8 @@ import {
53
55
  reapStaleAgents,
54
56
  reapStaleOrchestrators,
55
57
  releaseExpiredClaims,
58
+ expireQueuedMessages,
59
+ resolveQueuedPolicyMessages,
56
60
  releaseOrphanedTasks,
57
61
  validateAgentSession,
58
62
  upsertOrchestrator,
@@ -64,6 +68,18 @@ import {
64
68
  evaluatePoolBindings,
65
69
  ValidationError,
66
70
  } from "./db";
71
+ import {
72
+ deleteConfig,
73
+ getConfig,
74
+ getConfigHistory,
75
+ getManagedAgentState,
76
+ getSpawnPolicy,
77
+ listSpawnPolicies,
78
+ listConfig,
79
+ setConfig,
80
+ upsertManagedAgentState,
81
+ updateManagedAgentState,
82
+ } from "./config-store";
67
83
  import { createCommand, deleteCommand, expireCommands, getCommand, listCommands, updateCommand } from "./commands-db";
68
84
  import { getRecipe, listRecipes } from "./recipe-loader";
69
85
  import { applyCommandToRecipe, getRecipeInstance, listRecipeInstances, startRecipe, stopRecipe } from "./recipe-runner";
@@ -86,7 +102,7 @@ import {
86
102
  updateAutomation,
87
103
  type AutomationDispatchResult,
88
104
  } 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";
105
+ 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, SpawnPolicy, SpawnProvider, TaskStatus, TaskStatusInput } from "./types";
90
106
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
91
107
  import { listHostDirectories } from "./agent-spawn";
92
108
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
@@ -104,13 +120,19 @@ import {
104
120
  emitMessageClaimed,
105
121
  emitMessageClaimReleased,
106
122
  emitMessageDeleted,
123
+ emitMessageQueued,
124
+ emitMessageAvailable,
125
+ emitMessageExpired,
107
126
  emitTaskChanged,
108
127
  emitChannelActivity,
109
128
  emitOrchestratorStatus,
110
129
  emitOrchestratorRemoved,
111
130
  emitPoolBindingChanged,
131
+ emitConfigChanged,
132
+ emitManagedAgentStateChanged,
112
133
  } from "./sse";
113
134
  import { emitRelayEvent } from "./events";
135
+ import { isAbsolute, relative, resolve } from "node:path";
114
136
 
115
137
  type Handler = (
116
138
  req: Request,
@@ -195,7 +217,7 @@ function parseQueryInt(
195
217
 
196
218
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
197
219
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
198
- const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool"] as const;
220
+ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
199
221
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
200
222
  const VALID_AGENT_ACTIONS = ["restart", "shutdown"] as const;
201
223
  const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
@@ -212,6 +234,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
212
234
  return typeof value === "object" && value !== null && !Array.isArray(value);
213
235
  }
214
236
 
237
+ function pathWithinBase(path: string, baseDir: string): boolean {
238
+ const base = resolve(baseDir);
239
+ const target = resolve(path);
240
+ const rel = relative(base, target);
241
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
242
+ }
243
+
215
244
  function cleanString(
216
245
  value: unknown,
217
246
  field: string,
@@ -370,6 +399,12 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
370
399
  claimable: body.claimable as boolean | undefined,
371
400
  idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
372
401
  };
402
+ if (body.maxAgeSeconds !== undefined) {
403
+ if (typeof body.maxAgeSeconds !== "number" || !Number.isSafeInteger(body.maxAgeSeconds) || body.maxAgeSeconds < 0 || body.maxAgeSeconds > 2_592_000) {
404
+ throw new ValidationError("maxAgeSeconds must be an integer between 0 and 2592000");
405
+ }
406
+ input.maxAgeSeconds = body.maxAgeSeconds;
407
+ }
373
408
 
374
409
  const channel = cleanString(body.channel, "channel", { max: 120 });
375
410
  if (channel) input.channel = channel;
@@ -438,6 +473,7 @@ function normalizeChannelBindingInput(body: unknown): {
438
473
 
439
474
  function routeTargetFromAddress(target: string): ChannelRouteTarget {
440
475
  if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
476
+ if (target.startsWith("policy:")) return { type: "policy", id: target.slice("policy:".length) };
441
477
  if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
442
478
  if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
443
479
  if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
@@ -456,6 +492,7 @@ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: Cha
456
492
  if (target.type === "tag") return `tag:${target.id}`;
457
493
  if (target.type === "capability") return `cap:${target.id}`;
458
494
  if (target.type === "broadcast") return "broadcast";
495
+ if (target.type === "policy") return `policy:${target.id}`;
459
496
  return target.id;
460
497
  }
461
498
 
@@ -623,7 +660,7 @@ function resolveIntegrationEventTarget(input: IntegrationEventInput): Integratio
623
660
 
624
661
  const binding = bindings[0]!;
625
662
  const resolvedTarget = messageTargetForChannelTarget(binding.target, binding);
626
- if (!getAgent(resolvedTarget)) {
663
+ if (!resolvedTarget.startsWith("policy:") && !getAgent(resolvedTarget)) {
627
664
  throw new ValidationError(`channel ${channelId} binding resolves to ${resolvedTarget}; integration channel targets must resolve to one backend agent`);
628
665
  }
629
666
 
@@ -920,6 +957,25 @@ const postAgent: Handler = async (req) => {
920
957
  const input = normalizeAgentInput(parsed.body);
921
958
  const existing = getAgent(input.id);
922
959
  const agent = upsertAgent(input);
960
+ const policyName = metaString(agent.meta, "policyName");
961
+ const spawnRequestId = metaString(agent.meta, "spawnRequestId");
962
+ if (policyName && spawnRequestId) {
963
+ const state = getManagedAgentState(policyName);
964
+ if (state?.spawnRequestId === spawnRequestId) {
965
+ const updatedState = updateManagedAgentState(policyName, {
966
+ status: "running",
967
+ agentId: agent.id,
968
+ healthySince: Date.now(),
969
+ lastError: undefined,
970
+ });
971
+ if (updatedState) emitManagedAgentStateChanged(policyName, updatedState as unknown as Record<string, unknown>);
972
+ const available = resolveQueuedPolicyMessages(policyName, agent.id);
973
+ if (available.length) {
974
+ emitMessageAvailable(policyName, agent.id, available);
975
+ for (const message of available) emitNewMessage(message);
976
+ }
977
+ }
978
+ }
923
979
  emitAgentStatus(agent.id);
924
980
  if (!existing) {
925
981
  auditEvent({
@@ -1424,24 +1480,36 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1424
1480
  return error(`orchestrator does not support provider: ${provider}`);
1425
1481
  }
1426
1482
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
1427
- if (cwd && !cwd.startsWith(orch.baseDir)) {
1483
+ if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
1428
1484
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
1429
1485
  }
1430
1486
  const label = cleanString(parsed.body.label, "label", { max: 120 });
1431
1487
  const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
1432
1488
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1489
+ const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
1490
+ const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities") ?? [];
1491
+ const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs") ?? [];
1492
+ const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
1493
+ const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
1433
1494
 
1434
1495
  const command = createCommand({
1435
1496
  type: "agent.spawn",
1436
1497
  source: "system",
1437
1498
  target: orch.agentId,
1499
+ correlationId: spawnRequestId,
1438
1500
  params: {
1439
1501
  action: "spawn",
1440
1502
  provider,
1441
1503
  cwd: cwd || orch.baseDir,
1442
1504
  label,
1505
+ tags,
1506
+ capabilities,
1443
1507
  approvalMode,
1508
+ permissionMode: approvalMode,
1509
+ providerArgs,
1444
1510
  prompt,
1511
+ policyName,
1512
+ spawnRequestId,
1445
1513
  requestedBy: "dashboard",
1446
1514
  requestedAt: Date.now(),
1447
1515
  },
@@ -1475,12 +1543,17 @@ const postOrchestratorAction: Handler = async (req, params) => {
1475
1543
  const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown"] as const);
1476
1544
  if (!action) return error("action required");
1477
1545
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1546
+ const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
1547
+ const spawnRequestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 });
1548
+ const tmuxSession = cleanString(parsed.body.tmuxSession, "tmuxSession", { max: 200 });
1549
+ const reason = cleanString(parsed.body.reason, "reason", { max: 200 }) ?? "manual";
1478
1550
 
1479
1551
  const command = createCommand({
1480
1552
  type: action === "restart" ? "agent.restart" : "agent.shutdown",
1481
1553
  source: "system",
1482
1554
  target: agentId || orch.agentId,
1483
- params: { action, agentId, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
1555
+ correlationId: spawnRequestId,
1556
+ params: { action, agentId, policyName, spawnRequestId, tmuxSession, reason, requestedBy: "dashboard", requestedAt: Date.now(), orchestratorId: orch.id },
1484
1557
  });
1485
1558
  emitCommand(command);
1486
1559
  auditEvent({
@@ -1517,6 +1590,34 @@ const getOrchestratorDirectories: Handler = async (req, params) => {
1517
1590
  }
1518
1591
  };
1519
1592
 
1593
+ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path: string): Promise<Response> {
1594
+ const orch = getOrchestrator(orchestratorId);
1595
+ if (!orch) return error("orchestrator not found", 404);
1596
+ if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
1597
+ if (orch.status !== "online") return error("orchestrator is offline", 422);
1598
+ const incoming = new URL(req.url);
1599
+ const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
1600
+ const headers: Record<string, string> = {};
1601
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
1602
+ if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
1603
+ try {
1604
+ const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
1605
+ const contentType = res.headers.get("content-type") ?? "";
1606
+ if (contentType.includes("text/event-stream")) {
1607
+ return new Response(res.body, { status: res.status, headers: { "Content-Type": contentType } });
1608
+ }
1609
+ const body = await res.text();
1610
+ return new Response(body, { status: res.status, headers: { "Content-Type": contentType || "application/json" } });
1611
+ } catch (e) {
1612
+ return error(`Failed to reach orchestrator API: ${(e as Error).message}`, 502);
1613
+ }
1614
+ }
1615
+
1616
+ const getOrchestratorProviders: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/providers");
1617
+ const getOrchestratorSessions: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/sessions");
1618
+ const getOrchestratorVersion: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, "/api/version");
1619
+ const getOrchestratorLogs: Handler = (req, params) => proxyOrchestratorGet(req, params.id!, `/api/logs/${encodeURIComponent(params.session!)}`);
1620
+
1520
1621
  const deleteOrchestratorById: Handler = (_req, params) => {
1521
1622
  const deleted = deleteOrchestrator(params.id!);
1522
1623
  if (!deleted) return error("orchestrator not found", 404);
@@ -1612,6 +1713,262 @@ const patchCommand: Handler = async (req, params) => {
1612
1713
  }
1613
1714
  };
1614
1715
 
1716
+ // --- Config routes ---
1717
+
1718
+ function normalizeConfigPathParam(raw: string | undefined, field: string): string {
1719
+ return cleanString(raw, field, { required: true, max: 240 })!;
1720
+ }
1721
+
1722
+ const getConfigNamespace: Handler = (_req, params) => {
1723
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
1724
+ return json(listConfig(namespace));
1725
+ };
1726
+
1727
+ const getConfigKey: Handler = (_req, params) => {
1728
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
1729
+ const key = normalizeConfigPathParam(params.key, "key");
1730
+ const entry = getConfig(namespace, key);
1731
+ return entry ? json(entry) : error("config not found", 404);
1732
+ };
1733
+
1734
+ const putConfigKey: Handler = async (req, params) => {
1735
+ const parsed = await parseBody<unknown>(req);
1736
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1737
+ try {
1738
+ if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
1739
+ if (!Object.prototype.hasOwnProperty.call(parsed.body, "value")) throw new ValidationError("value required");
1740
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
1741
+ const key = normalizeConfigPathParam(params.key, "key");
1742
+ const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
1743
+ const entry = setConfig(namespace, key, parsed.body.value, updatedBy);
1744
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
1745
+ return json(entry, entry.version === 1 ? 201 : 200);
1746
+ } catch (e) {
1747
+ if (e instanceof ValidationError) return error(e.message, 400);
1748
+ throw e;
1749
+ }
1750
+ };
1751
+
1752
+ const deleteConfigKey: Handler = (req, params) => {
1753
+ try {
1754
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
1755
+ const key = normalizeConfigPathParam(params.key, "key");
1756
+ const updatedBy = cleanString(new URL(req.url).searchParams.get("updatedBy") ?? undefined, "updatedBy", { max: 200 });
1757
+ const existing = getConfig(namespace, key);
1758
+ if (!existing) return error("config not found", 404);
1759
+ deleteConfig(namespace, key, updatedBy);
1760
+ emitConfigChanged(namespace, key, existing.version + 1);
1761
+ return json({ ok: true });
1762
+ } catch (e) {
1763
+ if (e instanceof ValidationError) return error(e.message, 400);
1764
+ throw e;
1765
+ }
1766
+ };
1767
+
1768
+ const getConfigKeyHistory: Handler = (req, params) => {
1769
+ try {
1770
+ const namespace = normalizeConfigPathParam(params.namespace, "namespace");
1771
+ const key = normalizeConfigPathParam(params.key, "key");
1772
+ const limitRaw = parseQueryInt(new URL(req.url).searchParams.get("limit"), { min: 1, max: 500 });
1773
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
1774
+ return json(getConfigHistory(namespace, key, limitRaw ?? undefined));
1775
+ } catch (e) {
1776
+ if (e instanceof ValidationError) return error(e.message, 400);
1777
+ throw e;
1778
+ }
1779
+ };
1780
+
1781
+ // --- Spawn policy routes ---
1782
+
1783
+ function spawnRequestId(): string {
1784
+ return `sp_${crypto.randomUUID()}`;
1785
+ }
1786
+
1787
+ function policyStatusPayload(policy: SpawnPolicy) {
1788
+ return {
1789
+ policy,
1790
+ state: getManagedAgentState(policy.name) ?? {
1791
+ policyName: policy.name,
1792
+ status: "stopped",
1793
+ orchestratorId: policy.orchestratorId,
1794
+ provider: policy.provider,
1795
+ restartCount: 0,
1796
+ consecutiveFailures: 0,
1797
+ updatedAt: 0,
1798
+ },
1799
+ };
1800
+ }
1801
+
1802
+ function managedSpawnParams(policy: SpawnPolicy, requestId: string): Record<string, unknown> {
1803
+ return {
1804
+ action: "spawn",
1805
+ provider: policy.provider,
1806
+ cwd: policy.cwd,
1807
+ label: policy.label,
1808
+ tags: policy.tags,
1809
+ capabilities: policy.capabilities,
1810
+ approvalMode: policy.permissionMode,
1811
+ permissionMode: policy.permissionMode,
1812
+ providerArgs: policy.providerArgs,
1813
+ prompt: policy.prompt,
1814
+ headless: true,
1815
+ policyName: policy.name,
1816
+ spawnRequestId: requestId,
1817
+ requestedBy: "managed-agent",
1818
+ requestedAt: Date.now(),
1819
+ };
1820
+ }
1821
+
1822
+ function requirePolicyAndOrchestrator(name: string): { policy: SpawnPolicy; orch: NonNullable<ReturnType<typeof getOrchestrator>> } | Response {
1823
+ const entry = getSpawnPolicy(name);
1824
+ if (!entry) return error("spawn policy not found", 404);
1825
+ const policy = entry.value;
1826
+ const orch = getOrchestrator(policy.orchestratorId);
1827
+ if (!orch) return error("orchestrator not found", 404);
1828
+ return { policy, orch };
1829
+ }
1830
+
1831
+ function enqueuePolicyStart(policy: SpawnPolicy, reason: string): Command | Response {
1832
+ const orch = getOrchestrator(policy.orchestratorId);
1833
+ if (!orch) return error("orchestrator not found", 404);
1834
+ if (orch.status !== "online") return error("orchestrator is offline", 409);
1835
+ const requestId = spawnRequestId();
1836
+ const state = upsertManagedAgentState({
1837
+ policyName: policy.name,
1838
+ status: "starting",
1839
+ orchestratorId: policy.orchestratorId,
1840
+ provider: policy.provider,
1841
+ spawnRequestId: requestId,
1842
+ lastSpawnAt: Date.now(),
1843
+ });
1844
+ emitManagedAgentStateChanged(policy.name, state as unknown as Record<string, unknown>);
1845
+ const command = createCommand({
1846
+ type: "agent.spawn",
1847
+ source: "system",
1848
+ target: orch.agentId,
1849
+ correlationId: requestId,
1850
+ params: {
1851
+ ...managedSpawnParams(policy, requestId),
1852
+ reason,
1853
+ orchestratorId: orch.id,
1854
+ },
1855
+ });
1856
+ emitCommand(command);
1857
+ return command;
1858
+ }
1859
+
1860
+ function enqueuePolicyStop(policy: SpawnPolicy, action: "shutdown" | "restart", reason: string): Command | Response {
1861
+ const orch = getOrchestrator(policy.orchestratorId);
1862
+ if (!orch) return error("orchestrator not found", 404);
1863
+ const state = getManagedAgentState(policy.name);
1864
+ const nextState = upsertManagedAgentState({
1865
+ policyName: policy.name,
1866
+ status: action === "restart" ? "starting" : "stopping",
1867
+ agentId: state?.agentId,
1868
+ orchestratorId: policy.orchestratorId,
1869
+ provider: policy.provider,
1870
+ tmuxSession: state?.tmuxSession,
1871
+ spawnRequestId: state?.spawnRequestId,
1872
+ lastSpawnAt: state?.lastSpawnAt,
1873
+ lastStopAt: Date.now(),
1874
+ restartCount: state?.restartCount ?? 0,
1875
+ consecutiveFailures: state?.consecutiveFailures ?? 0,
1876
+ });
1877
+ emitManagedAgentStateChanged(policy.name, nextState as unknown as Record<string, unknown>);
1878
+ const command = createCommand({
1879
+ type: action === "restart" ? "agent.restart" : "agent.shutdown",
1880
+ source: "system",
1881
+ target: orch.agentId,
1882
+ correlationId: state?.spawnRequestId,
1883
+ params: {
1884
+ action,
1885
+ policyName: policy.name,
1886
+ spawnRequestId: state?.spawnRequestId,
1887
+ agentId: state?.agentId,
1888
+ tmuxSession: state?.tmuxSession,
1889
+ graceful: true,
1890
+ timeoutMs: 10_000,
1891
+ reason,
1892
+ orchestratorId: orch.id,
1893
+ requestedBy: "managed-agent",
1894
+ requestedAt: Date.now(),
1895
+ },
1896
+ });
1897
+ emitCommand(command);
1898
+ return command;
1899
+ }
1900
+
1901
+ const getSpawnPoliciesRoute: Handler = () => {
1902
+ return json(listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)));
1903
+ };
1904
+
1905
+ const getSpawnPolicyRoute: Handler = (_req, params) => {
1906
+ const entry = getSpawnPolicy(params.name!);
1907
+ return entry ? json(entry) : error("spawn policy not found", 404);
1908
+ };
1909
+
1910
+ const putSpawnPolicyRoute: Handler = async (req, params) => {
1911
+ const parsed = await parseBody<unknown>(req);
1912
+ if (!parsed.ok) return error(parsed.error, parsed.status);
1913
+ try {
1914
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
1915
+ ? parsed.body.value
1916
+ : parsed.body;
1917
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
1918
+ const entry = setConfig("spawn-policy", params.name!, value, updatedBy);
1919
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
1920
+ return json(entry, entry.version === 1 ? 201 : 200);
1921
+ } catch (e) {
1922
+ if (e instanceof ValidationError) return error(e.message, 400);
1923
+ throw e;
1924
+ }
1925
+ };
1926
+
1927
+ const deleteSpawnPolicyRoute: Handler = (req, params) => {
1928
+ const entry = getSpawnPolicy(params.name!);
1929
+ if (!entry) return error("spawn policy not found", 404);
1930
+ const stop = enqueuePolicyStop(entry.value, "shutdown", "policy-deleted");
1931
+ if (stop instanceof Response) return stop;
1932
+ deleteConfig("spawn-policy", params.name!, new URL(req.url).searchParams.get("updatedBy") ?? undefined);
1933
+ emitConfigChanged("spawn-policy", params.name!, entry.version + 1);
1934
+ return json({ ok: true, command: stop }, 202);
1935
+ };
1936
+
1937
+ const postSpawnPolicyStart: Handler = (_req, params) => {
1938
+ const resolved = requirePolicyAndOrchestrator(params.name!);
1939
+ if (resolved instanceof Response) return resolved;
1940
+ const command = enqueuePolicyStart(resolved.policy, "manual-start");
1941
+ return command instanceof Response ? command : json({ ok: true, command }, 202);
1942
+ };
1943
+
1944
+ const postSpawnPolicyStop: Handler = (_req, params) => {
1945
+ const resolved = requirePolicyAndOrchestrator(params.name!);
1946
+ if (resolved instanceof Response) return resolved;
1947
+ const command = enqueuePolicyStop(resolved.policy, "shutdown", "manual-stop");
1948
+ return command instanceof Response ? command : json({ ok: true, command }, 202);
1949
+ };
1950
+
1951
+ const postSpawnPolicyRestart: Handler = (_req, params) => {
1952
+ const resolved = requirePolicyAndOrchestrator(params.name!);
1953
+ if (resolved instanceof Response) return resolved;
1954
+ const command = enqueuePolicyStop(resolved.policy, "restart", "manual-restart");
1955
+ return command instanceof Response ? command : json({ ok: true, command }, 202);
1956
+ };
1957
+
1958
+ const getSpawnPolicyStatus: Handler = (_req, params) => {
1959
+ const entry = getSpawnPolicy(params.name!);
1960
+ return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
1961
+ };
1962
+
1963
+ const getSpawnPoliciesHealth: Handler = () => {
1964
+ return json(listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)));
1965
+ };
1966
+
1967
+ const getSpawnPolicyHealth: Handler = (_req, params) => {
1968
+ const entry = getSpawnPolicy(params.name!);
1969
+ return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
1970
+ };
1971
+
1615
1972
  // --- Provider config routes ---
1616
1973
 
1617
1974
  const VALID_PROVIDER_CONFIGS = ["claude", "codex"] as const;
@@ -1705,6 +2062,21 @@ const deleteCommandById: Handler = (_req, params) => {
1705
2062
 
1706
2063
  const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system"];
1707
2064
 
2065
+ const getQueuedMessagesRoute: Handler = (req) => {
2066
+ const url = new URL(req.url);
2067
+ const target = cleanString(url.searchParams.get("for") ?? undefined, "for", { required: true, max: 240 })!;
2068
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500 });
2069
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 500");
2070
+ return json(listQueuedMessages(target, limitRaw ?? undefined));
2071
+ };
2072
+
2073
+ const getMessageStatusRoute: Handler = (_req, params) => {
2074
+ const id = parseId(params.id);
2075
+ if (id === null) return error("invalid message id");
2076
+ const status = getMessageDeliveryStatus(id);
2077
+ return status ? json(status) : error("message not found", 404);
2078
+ };
2079
+
1708
2080
  const postMessage: Handler = async (req) => {
1709
2081
  const parsed = await parseBody<unknown>(req);
1710
2082
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -1724,7 +2096,8 @@ const postMessage: Handler = async (req) => {
1724
2096
  }
1725
2097
  const result = sendMessageWithResult(input);
1726
2098
  if (result.created) {
1727
- emitNewMessage(result.message);
2099
+ if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
2100
+ else emitNewMessage(result.message);
1728
2101
  const isWork = result.message.kind === "task";
1729
2102
  auditEvent({
1730
2103
  clientId: "server-message-" + result.message.id,
@@ -2221,7 +2594,9 @@ const postChannelEvent: Handler = async (req, params) => {
2221
2594
  claimable: false,
2222
2595
  }));
2223
2596
  for (const result of results) {
2224
- if (result.created) emitNewMessage(result.message);
2597
+ if (!result.created) continue;
2598
+ if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
2599
+ else emitNewMessage(result.message);
2225
2600
  }
2226
2601
  return json({
2227
2602
  messages: results.map((result) => result.message),
@@ -2570,12 +2945,14 @@ const getHealthRoute: Handler = () => json(getHealth());
2570
2945
  const postSystemReap: Handler = () => {
2571
2946
  const released = releaseExpiredClaims();
2572
2947
  const releasedOrphans = releaseOrphanedTasks();
2948
+ const expiredQueuedMessages = expireQueuedMessages();
2573
2949
  const expiredCommands = expireCommands();
2574
2950
  const reapedAgentIds = reapStaleAgents();
2575
2951
  const reapedOrchestratorIds = reapStaleOrchestrators();
2576
2952
  for (const id of released.messageIds) emitMessageClaimReleased(id);
2577
2953
  for (const task of released.tasks) emitTaskChanged(task, "task.updated");
2578
2954
  for (const task of releasedOrphans) emitTaskChanged(task, "task.updated");
2955
+ for (const message of expiredQueuedMessages) emitMessageExpired(message);
2579
2956
  for (const command of expiredCommands) {
2580
2957
  applyCommandToRecipe(command);
2581
2958
  emitCommand(command);
@@ -2600,7 +2977,7 @@ const postSystemReap: Handler = () => {
2600
2977
  clientId: "server-system-reap-" + Date.now(),
2601
2978
  kind: "state",
2602
2979
  title: "Maintenance reaper run",
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)`,
2980
+ body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${released.messageIds.length} message claim(s), ${released.tasks.length} task claim(s), ${expiredQueuedMessages.length} queued message(s), ${expiredCommands.length} command(s)`,
2604
2981
  icon: "ti-broom",
2605
2982
  view: "activity",
2606
2983
  });
@@ -2611,6 +2988,7 @@ const postSystemReap: Handler = () => {
2611
2988
  releasedMessageIds: released.messageIds,
2612
2989
  releasedTaskIds: released.tasks.map((task) => task.id),
2613
2990
  releasedOrphanedTaskIds: releasedOrphans.map((task) => task.id),
2991
+ expiredQueuedMessageIds: expiredQueuedMessages.map((message) => message.id),
2614
2992
  expiredCommandIds: expiredCommands.map((command) => command.id),
2615
2993
  });
2616
2994
  };
@@ -2660,6 +3038,10 @@ const routes: Route[] = [
2660
3038
  route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
2661
3039
  route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
2662
3040
  route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
3041
+ route("GET", "/api/orchestrators/:id/providers", getOrchestratorProviders),
3042
+ route("GET", "/api/orchestrators/:id/sessions", getOrchestratorSessions),
3043
+ route("GET", "/api/orchestrators/:id/version", getOrchestratorVersion),
3044
+ route("GET", "/api/orchestrators/:id/logs/:session", getOrchestratorLogs),
2663
3045
  route("DELETE", "/api/orchestrators/:id", deleteOrchestratorById),
2664
3046
 
2665
3047
  route("POST", "/api/system/broadcast", postSystemBroadcast),
@@ -2678,6 +3060,21 @@ const routes: Route[] = [
2678
3060
  route("GET", "/api/commands/:id", getCommandById),
2679
3061
  route("PATCH", "/api/commands/:id", patchCommand),
2680
3062
  route("DELETE", "/api/commands/:id", deleteCommandById),
3063
+ route("GET", "/api/config/:namespace", getConfigNamespace),
3064
+ route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
3065
+ route("GET", "/api/config/:namespace/:key", getConfigKey),
3066
+ route("PUT", "/api/config/:namespace/:key", putConfigKey),
3067
+ route("DELETE", "/api/config/:namespace/:key", deleteConfigKey),
3068
+ route("GET", "/api/spawn-policies", getSpawnPoliciesRoute),
3069
+ route("GET", "/api/spawn-policy/:name", getSpawnPolicyRoute),
3070
+ route("PUT", "/api/spawn-policy/:name", putSpawnPolicyRoute),
3071
+ route("DELETE", "/api/spawn-policy/:name", deleteSpawnPolicyRoute),
3072
+ route("POST", "/api/spawn-policy/:name/start", postSpawnPolicyStart),
3073
+ route("POST", "/api/spawn-policy/:name/stop", postSpawnPolicyStop),
3074
+ route("POST", "/api/spawn-policy/:name/restart", postSpawnPolicyRestart),
3075
+ route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
3076
+ route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
3077
+ route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
2681
3078
  route("GET", "/api/providers/config", getProviderConfigsRoute),
2682
3079
  route("GET", "/api/providers/:provider/config", getProviderConfigRoute),
2683
3080
  route("PUT", "/api/providers/:provider/config", putProviderConfigRoute),
@@ -2685,7 +3082,9 @@ const routes: Route[] = [
2685
3082
 
2686
3083
  route("POST", "/api/messages", postMessage),
2687
3084
  route("GET", "/api/messages", getMessages),
3085
+ route("GET", "/api/messages/queued", getQueuedMessagesRoute),
2688
3086
  route("GET", "/api/messages/cursor", getCursorRoute),
3087
+ route("GET", "/api/messages/:id/status", getMessageStatusRoute),
2689
3088
  route("GET", "/api/messages/:id", getMessageById),
2690
3089
  route("GET", "/api/messages/:id/thread", getMessageThread),
2691
3090
  route("POST", "/api/messages/:id/claim", postClaimMessage),
package/src/sse.ts CHANGED
@@ -70,6 +70,7 @@ function send(conn: Connection, event: string, data: unknown) {
70
70
  function messageMatchesAgent(msg: Message, agentId: string): boolean {
71
71
  const agent = getAgent(agentId);
72
72
  if (!agent) return false;
73
+ if (msg.resolvedToAgent === agentId) return true;
73
74
  if (msg.to === agentId || msg.from === agentId) return true;
74
75
  if (msg.to === "broadcast") return true;
75
76
  if (msg.to.startsWith("tag:") && agent.tags.includes(msg.to.slice(4))) return true;
@@ -130,6 +131,23 @@ export function emitMessageDeleted(messageId: number) {
130
131
  emitRelayEvent({ type: "message.deleted", source: "server", subject: String(messageId), data: { messageId } });
131
132
  }
132
133
 
134
+ export function emitMessageQueued(msg: Message) {
135
+ emitRelayEvent({ type: "message.queued", source: msg.from, subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
136
+ }
137
+
138
+ export function emitMessageAvailable(policyName: string, agentId: string, messages: Message[]) {
139
+ emitRelayEvent({
140
+ type: "message.available",
141
+ source: "server",
142
+ subject: `policy:${policyName}`,
143
+ data: { policyName, agentId, messageIds: messages.map((message) => message.id), count: messages.length },
144
+ });
145
+ }
146
+
147
+ export function emitMessageExpired(msg: Message) {
148
+ emitRelayEvent({ type: "message.expired", source: "server", subject: String(msg.id), data: msg as unknown as Record<string, unknown> });
149
+ }
150
+
133
151
  function sendTaskChanged(task: Task, eventType = "task.updated"): void {
134
152
  for (const conn of connections.values()) {
135
153
  if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
@@ -145,6 +163,14 @@ export function emitChannelActivity(activity: Record<string, unknown>) {
145
163
  emitRelayEvent({ type: "channel.activity", source: "server", data: activity });
146
164
  }
147
165
 
166
+ export function emitConfigChanged(namespace: string, key: string, version: number) {
167
+ emitRelayEvent({ type: "config.changed", source: "server", subject: `${namespace}:${key}`, data: { namespace, key, version } });
168
+ }
169
+
170
+ export function emitManagedAgentStateChanged(policyName: string, data: Record<string, unknown>) {
171
+ emitRelayEvent({ type: "policy.state.changed", source: "server", subject: policyName, data });
172
+ }
173
+
148
174
  export function getConnectionCount(): number {
149
175
  return connections.size;
150
176
  }